Add Tuya Cloud integration; 2-step add-board wizard; DP value formatting

- Tuya Cloud Gateway driver (tuya-device-sharing-sdk):
  - QR-code auth flow: user-provided user_code, terminal_id/endpoint
    returned by Tuya login_result (mirrors HA implementation)
  - Device sync, toggle DP, rename, per-device detail page
  - category → kind mapping; detect_switch_dps helper
  - format_dp_value: temperature (÷10 + °C/°F), humidity (+ %)
    registered as 'tuya_dp' Jinja2 filter

- TuyaDevice model (tuya_devices table)

- Templates:
  - tuya/gateway.html: device grid with live-reading sensor cards
    (config/threshold keys hidden from card, shown on detail page)
  - tuya/device.html: full status table with formatted DP values
  - tuya/auth_settings.html: user_code input + QR scan flow

- Add-board wizard refactored to 2-step flow:
  - Step 1: choose board type (Cloud Gateways vs Hardware)
  - Step 2: type-specific fields; gateways skip IP/relay fields

- Layout builder: Tuya chip support (makeTuyaChip, tuya_update socket)

- requirements.txt: tuya-device-sharing-sdk, cryptography
This commit is contained in:
ske087
2026-02-27 16:06:48 +02:00
parent 90cbf4e1f0
commit 1e89323035
19 changed files with 1873 additions and 84 deletions

View File

@@ -36,6 +36,7 @@ def create_app(config_name: str = "default") -> Flask:
from app.routes.api import api_bp
from app.routes.admin import admin_bp
from app.routes.sonoff import sonoff_bp
from app.routes.tuya import tuya_bp
from app.routes.layouts import layouts_bp
app.register_blueprint(auth_bp)
@@ -45,6 +46,7 @@ def create_app(config_name: str = "default") -> Flask:
app.register_blueprint(api_bp, url_prefix="/api")
app.register_blueprint(admin_bp, url_prefix="/admin")
app.register_blueprint(sonoff_bp)
app.register_blueprint(tuya_bp)
app.register_blueprint(layouts_bp, url_prefix="/layouts")
# ── user loader ───────────────────────────────────────────────────────────
@@ -59,11 +61,20 @@ def create_app(config_name: str = "default") -> Flask:
registry.discover()
app.logger.info("Board drivers loaded: %s", [d.DRIVER_ID for d in registry.all()])
# ── Jinja filters ─────────────────────────────────────────────────────────
from app.drivers.tuya_cloud.driver import format_dp_value as _tuya_fmt
@app.template_filter("tuya_dp")
def _tuya_dp_filter(value, dp_code: str, status=None):
"""Format a raw Tuya DP value for display (temperature scaling, units…)."""
return _tuya_fmt(dp_code, value, status)
# ── create tables & seed admin on first run ───────────────────────────────
with app.app_context():
# Import all models so their tables are registered before create_all
from app.models import board, user, workflow # noqa: F401
from app.models import sonoff_device # noqa: F401
from app.models import tuya_device # noqa: F401
from app.models import layout # noqa: F401
db.create_all()
_seed_admin(app)

View File

View File

@@ -0,0 +1,297 @@
"""Tuya Cloud Gateway Driver.
Implements the Tuya Device Sharing SDK integration. A single
"board" record represents an entire Tuya / Smart Life account.
Each discovered Tuya device is stored as a TuyaDevice record.
Auth flow (QR-code based):
1. User provides any short string as their "user_code".
2. Driver calls LoginControl().qr_code() → receives a QR token.
3. User scans the QR with the Tuya/Smart Life app.
4. Driver polls LoginControl().login_result() until success.
5. Tokens (access_token, refresh_token, etc.) are saved in board.config.
Device categories → kinds mapping follows the Tuya OpenAPI category definitions.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
# ── Tuya sharing app credentials (same as Home Assistant) ─────────────────────
TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke"
TUYA_SCHEMA = "haauthorize"
# ── Category code → kind ──────────────────────────────────────────────────────
CATEGORY_KIND: dict[str, str] = {
# Switches / sockets / power strips
"kg": "switch", "cz": "switch", "pc": "switch", "tdq": "switch",
"tgkg": "switch", "tgq": "switch", "dlq": "switch", "zndb": "switch",
# Lights
"dj": "light", "dd": "light", "xdd": "light", "fwd": "light",
"dc": "light", "tydj": "light", "gyd": "light",
# Fans
"fs": "fan", "fsd": "fan",
# Sensors
"wsdcg": "sensor", "co2bj": "sensor", "mcs": "sensor",
"pir": "sensor", "ldcg": "sensor", "pm2.5": "sensor",
# Covers / blinds
"cl": "cover", "clkg": "cover",
}
KIND_ICON: dict[str, str] = {
"switch": "bi-toggles",
"light": "bi-lightbulb-fill",
"fan": "bi-fan",
"sensor": "bi-thermometer-half",
"cover": "bi-door-open",
}
def category_kind(cat: str) -> str:
return CATEGORY_KIND.get(cat, "switch")
# ── DP value formatting ────────────────────────────────────────────────────────
# DP code substrings that indicate a temperature value stored as integer × 10
_TEMP_DP_SUBSTRINGS = ("temp",)
# Substrings that *contain* "temp" but are NOT scaled temperature readings
_TEMP_DP_EXCLUDE = ("unit_convert", "alarm")
# DP code substrings that carry a raw humidity percentage (0-100)
_HUMID_DP_SUBSTRINGS = ("humidity", "va_humidity", "hum_set", "hum_alarm")
def format_dp_value(dp_code: str, value, status: dict | None = None):
"""Return a human-readable representation of a Tuya DP value.
Rules
-----
* **Temperature DPs** (any code containing ``"temp"`` but not
``"unit_convert"`` or ``"alarm"``): raw integer is divided by 10
and the unit symbol (°C / °F) is appended based on the device's
``temp_unit_convert`` status key.
* **Humidity DPs**: raw integer is shown with a ``%`` suffix.
* **Booleans**: returned unchanged so the template can render badges.
* Everything else: returned unchanged.
"""
if isinstance(value, bool):
return value # template renders True/False as coloured badges
code = dp_code.lower()
# Temperature (Tuya encodes as integer × 10)
if (
any(sub in code for sub in _TEMP_DP_SUBSTRINGS)
and not any(exc in code for exc in _TEMP_DP_EXCLUDE)
and isinstance(value, (int, float))
):
unit_raw = (status or {}).get("temp_unit_convert", "c")
unit_sym = "°F" if str(unit_raw).lower() == "f" else "°C"
return f"{value / 10:.1f} {unit_sym}"
# Humidity (raw 0-100 %)
if any(sub in code for sub in _HUMID_DP_SUBSTRINGS) and isinstance(value, (int, float)):
return f"{value} %"
return value # pass through; template handles display
def detect_switch_dps(device) -> list[str]:
"""Return ordered list of writable boolean switch DP codes for a device.
Looks at device.function (writable DPs) and device.status keys.
Returns e.g. ['switch'], ['switch_1','switch_2'], etc.
"""
funcs = device.function if hasattr(device, "function") and device.function else {}
status = device.status if hasattr(device, "status") and device.status else {}
candidates = set(funcs.keys()) | set(status.keys())
dps: list[str] = []
if "switch" in candidates:
dps.append("switch")
for i in range(1, 10):
key = f"switch_{i}"
if key in candidates:
dps.append(key)
# Fallback: treat "led_switch" / "fan_switch" as single channel
if not dps:
for fallback in ("led_switch", "fan_switch", "switch_led"):
if fallback in candidates:
dps.append(fallback)
break
return dps or ["switch"]
def build_manager(board: "Board"):
"""Construct a tuya_sharing.Manager from this board's stored config.
The Manager handles token-refresh automatically. Any refreshed token
is written back to board.config so the next save() persists it.
"""
try:
from tuya_sharing import Manager, SharingTokenListener
except ImportError:
logger.error("tuya-device-sharing-sdk not installed; run: pip install tuya-device-sharing-sdk")
return None
cfg = board.config
token_info = cfg.get("tuya_token_info")
user_code = cfg.get("tuya_user_code", "")
terminal_id = cfg.get("tuya_terminal_id", "")
endpoint = cfg.get("tuya_endpoint", "https://apigw.iotbing.com")
if not token_info or not user_code:
logger.debug("Board %d has no Tuya token — skipping manager creation", board.id)
return None
class _Listener(SharingTokenListener):
def update_token(self, new_token_info: dict):
# Merge refreshed token back; caller must db.session.commit() later
cfg["tuya_token_info"] = new_token_info
board.config = cfg
try:
return Manager(
client_id=TUYA_CLIENT_ID,
user_code=user_code,
terminal_id=terminal_id,
end_point=endpoint,
token_response=token_info,
listener=_Listener(),
)
except Exception as exc:
logger.error("Cannot create Tuya Manager for board %d: %s", board.id, exc)
return None
# ── Driver class ───────────────────────────────────────────────────────────────
class TuyaCloudDriver(BoardDriver):
DRIVER_ID = "tuya_cloud"
DISPLAY_NAME = "Tuya Cloud Gateway"
DESCRIPTION = "Control Tuya / Smart Life devices via the Tuya Device Sharing cloud API"
DEFAULT_NUM_RELAYS = 0
DEFAULT_NUM_INPUTS = 0
# ── Abstract stubs (not used for gateway boards) ──────────────────────────
def get_relay_status(self, board, relay_num): return None
def set_relay(self, board, relay_num, state): return False
def toggle_relay(self, board, relay_num): return None
def register_webhook(self, board, callback_url): return True
# ── Core poll ─────────────────────────────────────────────────────────────
def poll(self, board: "Board") -> dict:
"""Sync device list + states from Tuya cloud."""
try:
self.sync_devices(board)
board.is_online = True
except Exception as exc:
logger.warning("Tuya poll failed for board %d: %s", board.id, exc)
board.is_online = False
return {"relay_states": {}, "input_states": {}, "is_online": board.is_online}
# ── Sync ──────────────────────────────────────────────────────────────────
def sync_devices(self, board: "Board") -> int:
"""Fetch the device list from Tuya cloud and upsert TuyaDevice rows."""
from app import db
from app.models.tuya_device import TuyaDevice
from datetime import datetime
mgr = build_manager(board)
if mgr is None:
return 0
mgr.update_device_cache()
# Persist any token that was refreshed during the update
try:
db.session.commit()
except Exception:
db.session.rollback()
existing: dict[str, TuyaDevice] = {
d.device_id: d
for d in TuyaDevice.query.filter_by(board_id=board.id).all()
}
synced = 0
for device_id, device in mgr.device_map.items():
dev = existing.get(device_id)
if dev is None:
dev = TuyaDevice(board_id=board.id, device_id=device_id)
db.session.add(dev)
if not dev.name:
dev.name = device.name or device_id
dev.category = device.category or ""
dev.product_name = getattr(device, "product_name", "") or ""
dev.is_online = device.online
dev.kind = category_kind(dev.category)
dev.kind_icon_cls = KIND_ICON.get(dev.kind, "bi-toggles")
switch_dps = detect_switch_dps(device)
dev.num_channels = len(switch_dps)
dev.switch_dps_json = json.dumps(switch_dps)
dev.status_json = json.dumps(dict(device.status) if device.status else {})
if device.online:
dev.last_seen = datetime.utcnow()
synced += 1
db.session.commit()
logger.info("Board %d: synced %d Tuya devices", board.id, synced)
return synced
# ── Control ───────────────────────────────────────────────────────────────
def set_dp(self, board: "Board", device_id: str, dp_code: str, value) -> bool:
"""Send a DP command (e.g. switch=True) to a Tuya device."""
mgr = build_manager(board)
if mgr is None:
return False
try:
mgr.send_commands(device_id, [{"code": dp_code, "value": value}])
self._apply_local(board, device_id, dp_code, value)
return True
except Exception as exc:
logger.error("Tuya set_dp %s.%s=%s failed: %s", device_id, dp_code, value, exc)
return False
def toggle_dp(self, board: "Board", device_id: str, dp_code: str) -> bool:
"""Toggle the boolean state of a DP."""
from app.models.tuya_device import TuyaDevice
dev = TuyaDevice.query.filter_by(
board_id=board.id, device_id=device_id
).first()
if not dev:
return False
current_val = dev.status.get(dp_code, False)
return self.set_dp(board, device_id, dp_code, not bool(current_val))
# ── Private ───────────────────────────────────────────────────────────────
@staticmethod
def _apply_local(board: "Board", device_id: str, dp_code: str, value) -> None:
"""Update the cached status immediately so the UI is consistent."""
from app import db
from app.models.tuya_device import TuyaDevice
dev = TuyaDevice.query.filter_by(
board_id=board.id, device_id=device_id
).first()
if dev:
s = dev.status
s[dp_code] = value
dev.status = s
try:
db.session.commit()
except Exception:
db.session.rollback()

View File

@@ -0,0 +1,5 @@
{
"driver_id": "tuya_cloud",
"name": "Tuya Cloud Gateway",
"description": "Control Tuya / Smart Life devices via the Tuya Device Sharing cloud API"
}

View File

@@ -56,6 +56,10 @@ class Board(db.Model):
"SonoffDevice", back_populates="board",
cascade="all, delete-orphan", lazy="dynamic"
)
tuya_devices = db.relationship(
"TuyaDevice", back_populates="board",
cascade="all, delete-orphan", lazy="dynamic"
)
# ── helpers ──────────────────────────────────────────────────────
@property

78
app/models/tuya_device.py Normal file
View File

@@ -0,0 +1,78 @@
"""TuyaDevice model represents a single Tuya / Smart Life sub-device
belonging to a Tuya Cloud Gateway board.
Each board (gateway) has many TuyaDevice rows, one per cloud device.
Controllable "channels" are boolean switch Data-Points (DPs) identified
by dp_code strings like "switch", "switch_1", "switch_2", …
"""
import json
from datetime import datetime
from app import db
class TuyaDevice(db.Model):
__tablename__ = "tuya_devices"
id = db.Column(db.Integer, primary_key=True)
board_id = db.Column(db.Integer, db.ForeignKey("boards.id"), nullable=False)
# Tuya global device ID (e.g. "bf0123456789abc")
device_id = db.Column(db.String(64), nullable=False)
# User-visible name
name = db.Column(db.String(128), default="")
# Tuya product category code (e.g. "kg"=switch, "dj"=light)
category = db.Column(db.String(32), default="")
# Full product name from Tuya cloud
product_name = db.Column(db.String(128), default="")
# Derived kind label: switch / light / fan / sensor / cover
kind = db.Column(db.String(32), default="switch")
# Bootstrap icon class for this kind
kind_icon_cls = db.Column(db.String(64), default="bi-toggles")
# Number of controllable switch channels
num_channels = db.Column(db.Integer, default=1)
# JSON list of switch DP codes in channel order, e.g. ["switch_1","switch_2"]
switch_dps_json = db.Column(db.Text, default='["switch"]')
# Full device status as JSON dict, e.g. {"switch":true,"countdown":0}
status_json = db.Column(db.Text, default="{}")
# Whether device is currently online
is_online = db.Column(db.Boolean, default=False)
last_seen = db.Column(db.DateTime, nullable=True)
board = db.relationship("Board", back_populates="tuya_devices")
# ── status helpers ────────────────────────────────────────────────────────
@property
def status(self) -> dict:
try:
return json.loads(self.status_json or "{}")
except (ValueError, TypeError):
return {}
@status.setter
def status(self, value: dict):
self.status_json = json.dumps(value)
# ── switch-DP helpers ─────────────────────────────────────────────────────
@property
def switch_dps(self) -> list[str]:
"""Ordered list of switch DP codes, e.g. ['switch_1', 'switch_2']."""
try:
v = json.loads(self.switch_dps_json or '["switch"]')
return v if v else ["switch"]
except (ValueError, TypeError):
return ["switch"]
def get_channel_state(self, idx: int) -> bool:
"""Return the on/off state of the switch channel at *idx*."""
dps = self.switch_dps
if not dps:
return False
dp_code = dps[idx] if idx < len(dps) else dps[0]
return bool(self.status.get(dp_code, False))
def dp_for_channel(self, idx: int) -> str:
"""Return the dp_code for channel at *idx*."""
dps = self.switch_dps
return dps[idx] if dps and idx < len(dps) else dps[0] if dps else "switch"

View File

@@ -32,9 +32,11 @@ def list_boards():
@login_required
def board_detail(board_id: int):
board = db.get_or_404(Board, board_id)
# Sonoff eWeLink gateway boards have their own page
# Gateway boards have their own dedicated page
if board.board_type == "sonoff_ewelink":
return redirect(url_for("sonoff.gateway", board_id=board_id))
if board.board_type == "tuya_cloud":
return redirect(url_for("tuya.gateway", board_id=board_id))
# Refresh states from device
poll_board(current_app._get_current_object(), board_id)
board = db.session.get(Board, board_id)
@@ -57,14 +59,17 @@ def add_board():
num_relays = int(request.form.get("num_relays", 4))
num_inputs = int(request.form.get("num_inputs", 4))
# Sonoff gateway doesn't need a real host address
is_gateway = board_type == "sonoff_ewelink"
# Gateway boards don't need a real host address
is_gateway = board_type in ("sonoff_ewelink", "tuya_cloud")
if not name or (not host and not is_gateway):
flash("Name and host are required.", "danger")
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())
if is_gateway:
host = host or "ewelink.cloud"
if board_type == "tuya_cloud":
host = host or "openapi.tuyaeu.com"
else:
host = host or "ewelink.cloud"
num_relays = 0
num_inputs = 0
@@ -84,6 +89,11 @@ def add_board():
register_webhook(board, server_url)
flash(f"Board '{name}' added successfully.", "success")
# Send gateway boards straight to their auth/settings page
if board_type == "sonoff_ewelink":
return redirect(url_for("sonoff.auth_settings", board_id=board.id))
if board_type == "tuya_cloud":
return redirect(url_for("tuya.auth_settings", board_id=board.id))
return redirect(url_for("boards.board_detail", board_id=board.id))
return render_template("boards/add.html", board_types=_board_types(), drivers=registry.all())

View File

@@ -9,6 +9,7 @@ from app import db
from app.models.layout import Layout
from app.models.board import Board
from app.models.sonoff_device import SonoffDevice
from app.models.tuya_device import TuyaDevice
layouts_bp = Blueprint("layouts", __name__)
@@ -53,6 +54,7 @@ def builder(layout_id: int):
for b in boards:
bd = {"id": b.id, "name": b.name, "online": b.is_online,
"relays": [], "inputs": [], "sonoff_channels": [],
"tuya_channels": [],
"board_type": b.board_type}
# ── Standard relay/input boards ──────────────────────────────────────
@@ -104,6 +106,33 @@ def builder(layout_id: int):
"isOnline": dev.is_online,
})
# ── Tuya Cloud sub-devices ────────────────────────────────────────────
if b.board_type == "tuya_cloud":
TUYA_KIND_ICON = {
"switch": "bi-toggles",
"light": "bi-lightbulb-fill",
"fan": "bi-fan",
"sensor": "bi-thermometer-half",
"cover": "bi-door-open",
}
tuya_devs = TuyaDevice.query.filter_by(board_id=b.id).order_by(
TuyaDevice.name).all()
for dev in tuya_devs:
icon = TUYA_KIND_ICON.get(dev.kind, "bi-plug")
for dp in dev.switch_dps:
idx = dev.switch_dps.index(dp)
label = (dev.name if dev.num_channels == 1
else f"{dev.name} Ch{idx + 1}")
bd["tuya_channels"].append({
"deviceId": dev.device_id,
"channel": dp, # dp_code string, e.g. "switch_1"
"name": label,
"icon": icon,
"kind": dev.kind,
"isOn": dev.status.get(dp, False),
"isOnline": dev.is_online,
})
boards_data.append(bd)
return render_template(

250
app/routes/tuya.py Normal file
View File

@@ -0,0 +1,250 @@
"""Tuya Cloud Gateway routes.
URL structure:
GET /tuya/<board_id> gateway overview
GET /tuya/<board_id>/auth auth settings page
POST /tuya/<board_id>/auth/qr (AJAX) generate QR code
GET /tuya/<board_id>/auth/poll (AJAX) poll login result
POST /tuya/<board_id>/auth/save (AJAX) persist tokens
POST /tuya/<board_id>/sync sync devices from cloud
GET /tuya/<board_id>/device/<device_id> device detail
POST /tuya/<board_id>/device/<device_id>/dp/<dp>/toggle toggle a DP
POST /tuya/<board_id>/device/<device_id>/rename rename a device
"""
from flask import (
Blueprint, abort, flash, jsonify, redirect,
render_template, request, url_for,
)
from flask_login import current_user, login_required
from app import db, socketio
from app.models.board import Board
from app.models.tuya_device import TuyaDevice
from app.drivers.registry import registry
from app.drivers.tuya_cloud.driver import (
TUYA_CLIENT_ID, TUYA_SCHEMA, category_kind, KIND_ICON,
)
tuya_bp = Blueprint("tuya", __name__, url_prefix="/tuya")
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_gateway(board_id: int) -> Board:
board = db.get_or_404(Board, board_id)
if board.board_type != "tuya_cloud":
abort(404)
return board
def _get_driver(board: Board):
drv = registry.get("tuya_cloud")
if drv is None:
abort(500)
return drv
# ── Gateway overview ──────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>")
@login_required
def gateway(board_id: int):
board = _get_gateway(board_id)
devices = (
TuyaDevice.query.filter_by(board_id=board_id)
.order_by(TuyaDevice.name)
.all()
)
has_token = bool(board.config.get("tuya_token_info"))
return render_template(
"tuya/gateway.html",
board=board,
devices=devices,
has_token=has_token,
)
# ── Auth settings ─────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/auth")
@login_required
def auth_settings(board_id: int):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
return render_template("tuya/auth_settings.html", board=board)
@tuya_bp.route("/<int:board_id>/auth/qr", methods=["POST"])
@login_required
def generate_qr(board_id: int):
"""AJAX: generate a new QR-code token for this board.
The caller must supply a ``user_code`` in the JSON body. This is a
meaningful identifier chosen by the user (e.g. a short string they
recognise) that Tuya uses to link the scan to their account. It must
**not** be auto-generated Tuya validates it server-side.
"""
if not current_user.is_admin():
abort(403)
_get_gateway(board_id)
data = request.get_json(silent=True) or {}
user_code = data.get("user_code", "").strip()
if not user_code:
return jsonify({"ok": False, "error": "A user code is required to generate the QR code."}), 400
try:
from tuya_sharing import LoginControl
lc = LoginControl()
response = lc.qr_code(TUYA_CLIENT_ID, TUYA_SCHEMA, user_code)
if not response.get("success"):
return jsonify({"ok": False, "error": response.get("msg", "QR generation failed")}), 400
qr_token = response["result"]["qrcode"]
# The URI that Smart Life / Tuya Smart app decodes from the QR:
qr_data = f"tuyaSmart--qrLogin?token={qr_token}"
return jsonify({
"ok": True,
"qr_data": qr_data,
"qr_token": qr_token,
"user_code": user_code,
})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
@tuya_bp.route("/<int:board_id>/auth/poll")
@login_required
def poll_login(board_id: int):
"""AJAX: check whether the user has scanned the QR code yet."""
qr_token = request.args.get("token", "")
user_code = request.args.get("user_code", "")
if not qr_token or not user_code:
return jsonify({"ok": False, "error": "Missing token or user_code"}), 400
try:
from tuya_sharing import LoginControl
lc = LoginControl()
ret, info = lc.login_result(qr_token, TUYA_CLIENT_ID, user_code)
if ret:
return jsonify({"ok": True, "info": info})
return jsonify({"ok": False, "pending": True})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
@tuya_bp.route("/<int:board_id>/auth/save", methods=["POST"])
@login_required
def save_auth(board_id: int):
"""AJAX: persist tokens after successful QR scan."""
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
data = request.get_json(silent=True) or {}
info = data.get("info", {})
user_code = data.get("user_code", "")
if not info or not user_code:
return jsonify({"ok": False, "error": "Missing data"}), 400
cfg = board.config
# Store the token blob (t, uid, expire_time, access_token, refresh_token)
cfg["tuya_token_info"] = {
"t": info.get("t", 0),
"uid": info.get("uid", ""),
"expire_time": info.get("expire_time", 7776000),
"access_token": info.get("access_token", ""),
"refresh_token": info.get("refresh_token", ""),
}
cfg["tuya_user_code"] = user_code
# terminal_id and endpoint are returned by Tuya in the login_result info dict
cfg["tuya_terminal_id"] = info.get("terminal_id", "")
cfg["tuya_endpoint"] = info.get("endpoint", "https://apigw.iotbing.com")
board.config = cfg
db.session.commit()
return jsonify({"ok": True, "redirect": url_for("tuya.gateway", board_id=board_id)})
# ── Sync devices ──────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/sync", methods=["POST"])
@login_required
def sync_devices(board_id: int):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
drv = _get_driver(board)
try:
n = drv.sync_devices(board)
db.session.commit()
flash(f"✓ Synced {n} Tuya device(s).", "success")
except Exception as exc:
flash(f"Sync failed: {exc}", "danger")
return redirect(url_for("tuya.gateway", board_id=board_id))
# ── Device detail ─────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/device/<device_id>")
@login_required
def device_detail(board_id: int, device_id: str):
board = _get_gateway(board_id)
device = TuyaDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
return render_template("tuya/device.html", board=board, device=device)
# ── Toggle ────────────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/device/<device_id>/dp/<dp_code>/toggle", methods=["POST"])
@login_required
def toggle_dp(board_id: int, device_id: str, dp_code: str):
board = _get_gateway(board_id)
device = TuyaDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
drv = _get_driver(board)
ok = drv.toggle_dp(board, device_id, dp_code)
new_state = device.status.get(dp_code, False)
# Broadcast to all connected clients
socketio.emit("tuya_update", {
"board_id": board_id,
"device_id": device_id,
"dp_code": dp_code,
"state": new_state,
})
if request.headers.get("Accept") == "application/json" or \
request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"ok": ok, "state": new_state})
return redirect(url_for("tuya.device_detail", board_id=board_id, device_id=device_id))
# ── Rename ────────────────────────────────────────────────────────────────────
@tuya_bp.route("/<int:board_id>/device/<device_id>/rename", methods=["POST"])
@login_required
def rename_device(board_id: int, device_id: str):
if not current_user.is_admin():
abort(403)
board = _get_gateway(board_id)
device = TuyaDevice.query.filter_by(
board_id=board_id, device_id=device_id
).first_or_404()
new_name = request.form.get("name", "").strip()
if new_name:
device.name = new_name
db.session.commit()
flash(f"Device renamed to '{new_name}'.", "success")
return redirect(url_for("tuya.device_detail", board_id=board_id, device_id=device_id))

View File

@@ -257,11 +257,15 @@
// relay type badge
const typeText = opts.entityType === "relay" ? "relay"
: opts.entityType === "sonoff" ? "sonoff"
: opts.entityType === "tuya" ? "tuya"
: "input";
const badge = new Konva.Text({
x: 36, y: 38,
text: typeText, fontSize: 9, fontFamily: "system-ui, sans-serif",
fill: opts.entityType === "relay" ? "#f0883e" : opts.entityType === "sonoff" ? "#58a6ff" : "#3fb950",
fill: opts.entityType === "relay" ? "#f0883e"
: opts.entityType === "sonoff" ? "#58a6ff"
: opts.entityType === "tuya" ? "#20c997"
: "#3fb950",
name: "device-type",
});
@@ -310,6 +314,8 @@
.replace("{relayNum}", opts.entityNum);
} else if (opts.entityType === "sonoff") {
url = `/sonoff/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/ch/${opts.channel}/toggle`;
} else if (opts.entityType === "tuya") {
url = `/tuya/${opts.boardId}/device/${encodeURIComponent(opts.deviceId)}/dp/${encodeURIComponent(opts.channel)}/toggle`;
} else {
return; // inputs are read-only
}
@@ -399,6 +405,14 @@
isOn = entityInfo.isOn;
onColor = entityInfo.kind === "light" ? "warning" : "success";
offColor = "secondary";
} else if (d.entityType === "tuya") {
// Match by deviceId + channel (dp_code) stored in tuya_channels
entityInfo = board.tuya_channels &&
board.tuya_channels.find(tc => tc.deviceId === d.deviceId && tc.channel === d.channel);
if (!entityInfo) return;
isOn = entityInfo.isOn;
onColor = entityInfo.kind === "light" ? "warning" : "success";
offColor = "secondary";
} else if (d.entityType === "relay") {
entityInfo = board.relays.find(r => r.num === d.entityNum);
if (!entityInfo) return;
@@ -427,7 +441,7 @@
boardName: board.name,
icon: entityInfo.icon,
stateColor: colorForState(isOn, onColor, offColor),
isRelay: d.entityType === "relay" || d.entityType === "sonoff",
isRelay: d.entityType === "relay" || d.entityType === "sonoff" || d.entityType === "tuya",
});
deviceLayer.add(group);
});
@@ -837,6 +851,13 @@
hasAny = true;
});
}
// ── Tuya sub-devices ─────────────────────────────────────────────
if (board.tuya_channels && board.tuya_channels.length) {
board.tuya_channels.forEach(tc => {
grp.appendChild(makeTuyaChip(board, tc));
hasAny = true;
});
}
if (hasAny) list.appendChild(grp);
});
@@ -928,6 +949,46 @@
return chip;
}
function makeTuyaChip(board, tc) {
const chip = document.createElement("div");
chip.className = "lb-device-chip";
chip.draggable = true;
chip.dataset.boardId = board.id;
chip.dataset.entityType = "tuya";
chip.dataset.deviceId = tc.deviceId;
chip.dataset.channel = tc.channel; // dp_code string
chip.dataset.name = tc.name;
chip.dataset.boardName = board.name;
chip.dataset.isOn = tc.isOn;
const onColor = tc.kind === "light" ? "warning" : "success";
const dotColor = colorForState(tc.isOn, onColor, "secondary");
chip.innerHTML = `
<span class="chip-dot" style="background:${dotColor}"></span>
<i class="bi ${escHtml(tc.icon)}" style="font-size:13px"></i>
<span class="text-truncate">${escHtml(tc.name)}</span>
<span class="ms-auto text-secondary" style="font-size:.7rem">${escHtml(tc.kind || 'switch')}</span>`;
chip.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/json", JSON.stringify({
boardId: parseInt(board.id),
entityType: "tuya",
entityNum: null,
deviceId: tc.deviceId,
channel: tc.channel,
name: tc.name,
boardName: board.name,
onColor,
offColor: "secondary",
isOn: tc.isOn,
}));
});
return chip;
}
// ── canvas drop target ────────────────────────────────────────────────────
const ghost = document.getElementById("lb-drag-ghost");
@@ -979,7 +1040,7 @@
name: dragged.name,
boardName: dragged.boardName,
stateColor,
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff",
isRelay: dragged.entityType === "relay" || dragged.entityType === "sonoff" || dragged.entityType === "tuya",
});
deviceLayer.add(group);
@@ -1020,6 +1081,25 @@
});
});
// Tuya devices emit a "tuya_update" event
socket.on("tuya_update", (data) => {
const bid = data.board_id;
deviceLayer.find(".device-group").forEach(group => {
if (group.attrs.boardId !== bid) return;
if (group.attrs.entityType !== "tuya") return;
if (group.attrs.deviceId !== data.device_id) return;
if (group.attrs.channel !== data.dp_code) return;
const board = CFG.boards.find(b => b.id === bid);
const tc = board && board.tuya_channels &&
board.tuya_channels.find(t =>
t.deviceId === data.device_id && t.channel === data.dp_code);
const onColor = tc ? (tc.kind === "light" ? "warning" : "success") : "success";
updateDeviceState(group, data.state, onColor, "secondary");
});
});
// Sonoff sub-devices emit a separate "sonoff_update" event
socket.on("sonoff_update", (data) => {
const bid = data.board_id;

View File

@@ -2,99 +2,340 @@
{% block title %}Add Board Location Management{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item active">Add Board</li>
</ol>
</nav>
<div class="card border-0 rounded-4" style="max-width:640px">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-plus-circle me-1 text-primary"></i> Add New Board
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label">Local Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. Server Room Board" required />
</div>
<div class="mb-3">
<label class="form-label">Board Type</label>
<select name="board_type" class="form-select" id="board_type_select" onchange="updateDefaults(this.value)">
{% for value, label in board_types %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<div class="form-text text-secondary" id="driver_desc"></div>
</div>
{# ── Hardware board fields (hidden for Sonoff gateway) ── #}
<div id="hw_fields">
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label">IP Address / Hostname</label>
<input type="text" name="host" id="host_input" class="form-control font-monospace" placeholder="192.168.1.100" />
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label">Number of Relays</label>
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="0" max="32" />
</div>
<div class="col-6">
<label class="form-label">Number of Inputs</label>
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
</div>
</div>
</div>
{# ── Step indicator ──────────────────────────────────────────────────────── #}
<div class="d-flex align-items-center mb-4" id="step-indicator" style="max-width:420px">
<div class="step-bubble active" id="bubble-1">1</div>
<span class="small fw-semibold ms-2 me-3" id="lbl-1">Choose Type</span>
<div class="step-line" id="step-line"></div>
<div class="step-bubble ms-3 me-2" id="bubble-2">2</div>
<span class="small text-muted" id="lbl-2">Configure</span>
</div>
{# ── Sonoff gateway info ── #}
<div id="sonoff_info" class="d-none mb-4">
<div class="alert alert-info d-flex gap-3 align-items-start py-3">
<i class="bi bi-cloud-lightning-fill fs-4 text-warning mt-1"></i>
{# ══════════════════════════════════════════════════════════
STEP 1 pick a board / integration type
══════════════════════════════════════════════════════════ #}
<div id="step1">
<h5 class="fw-bold mb-1">What are you adding?</h5>
<p class="text-muted small mb-4">Choose the hardware board or cloud service you want to connect.</p>
<p class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.07em">
<i class="bi bi-cloud me-1"></i>Cloud Gateways
</p>
<div class="row g-3 mb-4">
<div class="col-12 col-sm-6">
<button type="button" class="type-card w-100 text-start" data-type="sonoff_ewelink">
<div class="d-flex align-items-start gap-3">
<div class="type-icon" style="background:rgba(255,210,0,.12);color:#ffd200">
<i class="bi bi-cloud-lightning-fill"></i>
</div>
<div>
<strong>Sonoff eWeLink Gateway</strong><br>
<span class="small">
After adding, you'll be redirected to set up your eWeLink account credentials.
All your Sonoff devices will be discovered automatically from the cloud.
LAN control is used when devices are reachable on the local network.
</span>
<div class="fw-semibold">Sonoff eWeLink</div>
<div class="small text-muted mt-1">Control Sonoff devices via the eWeLink cloud.</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</button>
</div>
<div class="col-12 col-sm-6">
<button type="button" class="type-card w-100 text-start" data-type="tuya_cloud">
<div class="d-flex align-items-start gap-3">
<div class="type-icon" style="background:rgba(32,201,151,.12);color:#20c997">
<i class="bi bi-cloud-fill"></i>
</div>
<div>
<div class="fw-semibold">Tuya Cloud</div>
<div class="small text-muted mt-1">Control Tuya / Smart Life devices via QR login.</div>
</div>
</div>
</button>
</div>
</div>
<p class="text-uppercase text-muted fw-semibold small mb-2" style="letter-spacing:.07em">
<i class="bi bi-cpu me-1"></i>Hardware Boards
</p>
<div class="row g-3">
{% for d in drivers %}
{% if d.DRIVER_ID not in ("sonoff_ewelink", "tuya_cloud") %}
<div class="col-12 col-sm-6 col-lg-4">
<button type="button" class="type-card w-100 text-start" data-type="{{ d.DRIVER_ID }}">
<div class="d-flex align-items-start gap-3">
<div class="type-icon" style="background:rgba(88,166,255,.12);color:#58a6ff">
<i class="bi bi-cpu-fill"></i>
</div>
<div>
<div class="fw-semibold">{{ d.DRIVER_ID | replace('_',' ') | title }}</div>
<div class="small text-muted mt-1">{{ d.DESCRIPTION }}</div>
</div>
</div>
</button>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{# ══════════════════════════════════════════════════════════
STEP 2 configuration form
══════════════════════════════════════════════════════════ #}
<div id="step2" class="d-none" style="max-width:560px">
<button type="button" class="btn btn-link btn-sm text-muted ps-0 mb-3" id="btn-back">
<i class="bi bi-arrow-left me-1"></i>Back
</button>
{# Selected type badge #}
<div class="d-flex align-items-center gap-3 mb-4">
<div class="type-icon-sm" id="s2-icon-wrap"></div>
<div>
<div class="fw-bold fs-6" id="s2-type-label"></div>
<div class="small text-muted" id="s2-type-desc"></div>
</div>
</div>
<form method="POST" id="add-form">
<input type="hidden" name="board_type" id="f-board-type">
{# Name always shown #}
<div class="mb-4">
<label class="form-label fw-semibold">Board Name</label>
<input type="text" name="name" id="f-name" class="form-control"
placeholder="e.g. Living Room Gateway" required autofocus>
<div class="form-text text-muted">A friendly name to identify this board.</div>
</div>
{# ── Gateway info box (Sonoff / Tuya) ── #}
<div id="f-gw-section" class="d-none mb-4">
<div class="alert py-3 d-flex gap-3 align-items-start" id="f-gw-alert">
<span id="f-gw-icon" class="fs-4 mt-1"></span>
<div id="f-gw-text"></div>
</div>
</div>
{# ── Hardware-only fields ── #}
<div id="f-hw-section" class="d-none">
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label fw-semibold">IP Address / Hostname</label>
<input type="text" name="host" id="f-host" class="form-control font-monospace"
placeholder="192.168.1.100">
</div>
<div class="col-4">
<label class="form-label fw-semibold">Port</label>
<input type="number" name="port" class="form-control" value="80" min="1" max="65535">
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label fw-semibold">Relay Outputs</label>
<input type="number" name="num_relays" id="f-relays" class="form-control"
value="4" min="0" max="32">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Digital Inputs</label>
<input type="number" name="num_inputs" id="f-inputs" class="form-control"
value="4" min="0" max="32">
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-lg me-1"></i>Add Board
</button>
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
<style>
.type-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: .75rem;
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color .15s, background .15s, box-shadow .15s;
color: inherit;
}
.type-card:hover, .type-card:focus {
border-color: #58a6ff;
background: rgba(88,166,255,.06);
box-shadow: 0 0 0 3px rgba(88,166,255,.12);
outline: none;
}
.type-icon {
width: 44px; height: 44px; border-radius: .55rem;
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; flex-shrink: 0;
}
.type-icon-sm {
width: 38px; height: 38px; border-radius: .45rem;
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; flex-shrink: 0;
}
.step-bubble {
width: 28px; height: 28px; border-radius: 50%;
background: #30363d; color: #8b949e;
display: flex; align-items: center; justify-content: center;
font-size: .8rem; font-weight: 700; flex-shrink: 0;
transition: background .2s, color .2s;
}
.step-bubble.active { background: #1f6feb; color: #fff; }
.step-bubble.done { background: #238636; color: #fff; }
.step-line {
flex: 1; height: 2px; background: #30363d;
min-width: 32px; max-width: 60px;
transition: background .2s;
}
.step-line.done { background: #238636; }
</style>
{% endblock %}
{% block scripts %}
<script>
const driverDefaults = {
{% for d in drivers %}
"{{ d.DRIVER_ID }}": { relays: {{ d.DEFAULT_NUM_RELAYS }}, inputs: {{ d.DEFAULT_NUM_INPUTS }}, desc: "{{ d.DESCRIPTION }}" },
{% endfor %}
};
function updateDefaults(type) {
const d = driverDefaults[type] || { relays: 4, inputs: 4, desc: "" };
document.getElementById("num_relays").value = d.relays;
document.getElementById("num_inputs").value = d.inputs;
document.getElementById("driver_desc").textContent = d.desc;
(function () {
const DRIVERS = {
{% for d in drivers %}
"{{ d.DRIVER_ID }}": {
relays: {{ d.DEFAULT_NUM_RELAYS }},
inputs: {{ d.DEFAULT_NUM_INPUTS }},
desc: "{{ d.DESCRIPTION | replace('"', '\\"') }}",
},
{% endfor %}
};
const isGateway = type === "sonoff_ewelink";
document.getElementById("hw_fields").classList.toggle("d-none", isGateway);
document.getElementById("sonoff_info").classList.toggle("d-none", !isGateway);
// host is not required for gateway
document.getElementById("host_input").required = !isGateway;
}
// init on page load
updateDefaults(document.getElementById("board_type_select").value);
const GW_META = {
sonoff_ewelink: {
label: "Sonoff eWeLink",
iconHtml: '<i class="bi bi-cloud-lightning-fill"></i>',
iconBg: "rgba(255,210,0,.15)",
iconColor: "#ffd200",
alertCls: "alert-warning",
alertIcon: '<i class="bi bi-cloud-lightning-fill text-warning"></i>',
alertBody: '<strong>Sonoff eWeLink Gateway</strong><br>'
+ '<span class="small">After adding, you\'ll enter your eWeLink '
+ 'account credentials to discover and control all your Sonoff devices '
+ 'automatically.</span>',
},
tuya_cloud: {
label: "Tuya Cloud",
iconHtml: '<i class="bi bi-cloud-fill"></i>',
iconBg: "rgba(32,201,151,.15)",
iconColor: "#20c997",
alertCls: "alert-info",
alertIcon: '<i class="bi bi-qr-code text-info"></i>',
alertBody: '<strong>Tuya / Smart Life Cloud Gateway</strong><br>'
+ '<span class="small">After adding, you\'ll scan a QR code with '
+ 'the <strong>Smart Life</strong> or <strong>Tuya Smart</strong> app '
+ 'to link your account and discover all your Tuya devices automatically.</span>',
},
};
const GATEWAY_TYPES = Object.keys(GW_META);
// DOM
const step1 = document.getElementById("step1");
const step2 = document.getElementById("step2");
const btnBack = document.getElementById("btn-back");
const bubble1 = document.getElementById("bubble-1");
const bubble2 = document.getElementById("bubble-2");
const stepLine = document.getElementById("step-line");
const lbl1 = document.getElementById("lbl-1");
const lbl2 = document.getElementById("lbl-2");
const fType = document.getElementById("f-board-type");
const fName = document.getElementById("f-name");
const fHost = document.getElementById("f-host");
const fRelays = document.getElementById("f-relays");
const fInputs = document.getElementById("f-inputs");
const hwSec = document.getElementById("f-hw-section");
const gwSec = document.getElementById("f-gw-section");
const gwAlert = document.getElementById("f-gw-alert");
const gwIcon = document.getElementById("f-gw-icon");
const gwText = document.getElementById("f-gw-text");
const s2Icon = document.getElementById("s2-icon-wrap");
const s2Label = document.getElementById("s2-type-label");
const s2Desc = document.getElementById("s2-type-desc");
// Card clicks
document.querySelectorAll(".type-card").forEach(function (card) {
card.addEventListener("click", function () { goStep2(card.dataset.type); });
});
// Back
btnBack.addEventListener("click", function () {
step2.classList.add("d-none");
step1.classList.remove("d-none");
bubble1.classList.replace("done", "active");
bubble2.classList.remove("active");
stepLine.classList.remove("done");
lbl1.classList.remove("text-success");
lbl2.classList.add("text-muted");
lbl2.classList.remove("fw-semibold");
});
function goStep2(type) {
const drv = DRIVERS[type] || { relays: 4, inputs: 4, desc: "" };
const meta = GW_META[type] || null;
const isGw = GATEWAY_TYPES.includes(type);
fType.value = type;
fRelays.value = drv.relays;
fInputs.value = drv.inputs;
fName.value = "";
// Header icon + label
if (meta) {
s2Icon.innerHTML = meta.iconHtml;
s2Icon.style.background = meta.iconBg;
s2Icon.style.color = meta.iconColor;
s2Label.textContent = meta.label;
} else {
s2Icon.innerHTML = '<i class="bi bi-cpu-fill"></i>';
s2Icon.style.background = "rgba(88,166,255,.15)";
s2Icon.style.color = "#58a6ff";
s2Label.textContent = type.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
s2Desc.textContent = drv.desc;
// Section visibility
if (isGw) {
hwSec.classList.add("d-none");
fHost.required = false;
gwSec.classList.remove("d-none");
gwAlert.className = "alert py-3 d-flex gap-3 align-items-start " + meta.alertCls;
gwIcon.innerHTML = meta.alertIcon;
gwText.innerHTML = meta.alertBody;
} else {
hwSec.classList.remove("d-none");
fHost.required = true;
gwSec.classList.add("d-none");
}
// Step indicator
step1.classList.add("d-none");
step2.classList.remove("d-none");
bubble1.classList.remove("active");
bubble1.classList.add("done");
bubble2.classList.add("active");
stepLine.classList.add("done");
lbl1.classList.add("text-success");
lbl2.classList.remove("text-muted");
lbl2.classList.add("fw-semibold");
fName.focus();
}
}());
</script>
{% endblock %}

View File

@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} Tuya Settings{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:560px">
<a href="{{ url_for('tuya.gateway', board_id=board.id) }}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left me-1"></i>Back to Gateway
</a>
<div class="card bg-dark border-secondary">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-qr-code me-2 text-info"></i>Tuya / Smart Life Account
</h5>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} py-2 alert-dismissible fade show" role="alert">
{{ msg }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<p class="text-muted small mb-4">
Link your <strong>Tuya / Smart Life</strong> account by scanning a QR code with
the mobile app. No tokens or passwords are stored in plain text only the
access/refresh token pair returned by SSO is persisted.
</p>
{# Current status #}
{% if board.config.get('tuya_token_info') %}
<div class="alert alert-success py-2 d-flex align-items-center gap-2" id="linked-status">
<i class="bi bi-check-circle-fill"></i>
<span>
Account linked.
<small class="opacity-75">(User code: {{ board.config.get('tuya_user_code', '?') }})</small>
</span>
</div>
{% else %}
<div class="alert alert-secondary py-2 d-flex align-items-center gap-2" id="linked-status">
<i class="bi bi-x-circle"></i>
<span>No account linked yet.</span>
</div>
{% endif %}
{# QR section #}
<div id="qr-section">
<div class="mb-3">
<label for="input-user-code" class="form-label fw-semibold">Your Tuya User Code</label>
<input type="text" id="input-user-code" class="form-control"
placeholder="e.g. myname123"
value="{{ board.config.get('tuya_user_code', '') }}">
<div class="form-text">
Choose any short identifier for this installation (letters and numbers, no spaces).
You will need to remember it if you ever re-link the account.
</div>
</div>
<button id="btn-start-qr" class="btn btn-info mb-3">
<i class="bi bi-qr-code me-1"></i>Generate QR Code
</button>
<div id="qr-area" class="text-center d-none">
<div id="qr-canvas" class="d-inline-block p-2 bg-white rounded mb-3"></div>
<p class="text-muted small mb-1" id="qr-instruction">
Open the <strong>Smart Life</strong> (or Tuya Smart) app → tap the
<i class="bi bi-qr-code-scan"></i> scan icon → scan this QR code.
</p>
<div class="d-flex align-items-center justify-content-center gap-2 mt-2">
<div class="spinner-border spinner-border-sm text-info" role="status" id="qr-spinner">
<span class="visually-hidden">Waiting…</span>
</div>
<span class="text-muted small" id="qr-poll-msg">Waiting for scan…</span>
</div>
<button id="btn-cancel-qr" class="btn btn-outline-secondary btn-sm mt-3">Cancel</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- QR code renderer (MIT, CDN) -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
<script>
(function() {
const BOARD_ID = {{ board.id }};
const QR_URL = "{{ url_for('tuya.generate_qr', board_id=board.id) }}";
const POLL_URL = "{{ url_for('tuya.poll_login', board_id=board.id) }}";
const SAVE_URL = "{{ url_for('tuya.save_auth', board_id=board.id) }}";
const GW_URL = "{{ url_for('tuya.gateway', board_id=board.id) }}";
let pollTimer = null;
let userCode = null;
let qrToken = null;
const btnStart = document.getElementById('btn-start-qr');
const btnCancel = document.getElementById('btn-cancel-qr');
const qrArea = document.getElementById('qr-area');
const qrCanvas = document.getElementById('qr-canvas');
const spinner = document.getElementById('qr-spinner');
const pollMsg = document.getElementById('qr-poll-msg');
const statusDiv = document.getElementById('linked-status');
const inputCode = document.getElementById('input-user-code');
btnStart.addEventListener('click', startQr);
btnCancel.addEventListener('click', cancelQr);
function startQr() {
const code = inputCode.value.trim();
if (!code) {
inputCode.focus();
inputCode.classList.add('is-invalid');
return;
}
inputCode.classList.remove('is-invalid');
btnStart.disabled = true;
btnStart.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating…';
fetch(QR_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_code: code }),
})
.then(r => r.json())
.then(function(data) {
if (!data.ok) { showError(data.error); btnStart.disabled = false; btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code'; return; }
userCode = data.user_code;
qrToken = data.qr_token;
// Render QR code into canvas div
qrCanvas.innerHTML = '';
QRCode.toCanvas(document.createElement('canvas'), data.qr_data, { width: 220 }, function(err, canvas) {
if (err) { showError('QR render failed: ' + err); return; }
qrCanvas.appendChild(canvas);
});
qrArea.classList.remove('d-none');
btnStart.classList.add('d-none');
inputCode.disabled = true;
pollMsg.textContent = 'Waiting for scan…';
spinner.classList.remove('d-none');
// Start polling every 3 s
pollTimer = setInterval(pollLogin, 3000);
})
.catch(function(err) { showError(err); btnStart.disabled = false; btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code'; });
}
function pollLogin() {
const url = POLL_URL + '?token=' + encodeURIComponent(qrToken) + '&user_code=' + encodeURIComponent(userCode);
fetch(url)
.then(r => r.json())
.then(function(data) {
if (data.ok) {
clearInterval(pollTimer);
pollMsg.textContent = '✓ Scanned! Saving…';
spinner.classList.add('d-none');
saveTokens(data.info);
} else if (data.error) {
clearInterval(pollTimer);
pollMsg.textContent = 'Error: ' + data.error;
spinner.classList.add('d-none');
}
// else data.pending: keep waiting
})
.catch(function() { /* network hiccup keep polling */ });
}
function saveTokens(info) {
fetch(SAVE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ info: info, user_code: userCode }),
})
.then(r => r.json())
.then(function(data) {
if (data.ok) {
pollMsg.textContent = '✓ Account linked successfully! Redirecting…';
setTimeout(function() { window.location.href = data.redirect || GW_URL; }, 1500);
} else {
showError(data.error || 'Save failed');
}
})
.catch(function(err) { showError(err); });
}
function cancelQr() {
clearInterval(pollTimer);
qrArea.classList.add('d-none');
btnStart.classList.remove('d-none');
btnStart.disabled = false;
btnStart.innerHTML = '<i class="bi bi-qr-code me-1"></i>Generate QR Code';
qrCanvas.innerHTML = '';
inputCode.disabled = false;
}
function showError(msg) {
const el = document.createElement('div');
el.className = 'alert alert-danger py-2 mt-2';
el.textContent = msg;
qrCanvas.after(el);
}
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}{{ device.name }} Tuya Device{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:640px">
{# ── Breadcrumb ──────────────────────────────────────────────────── #}
<nav aria-label="breadcrumb" class="small mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('tuya.gateway', board_id=board.id) }}">{{ board.name }}</a></li>
<li class="breadcrumb-item active">{{ device.name }}</li>
</ol>
</nav>
{# ── Device card ─────────────────────────────────────────────────── #}
{% set icon_cls = {
'switch': 'bi-toggles',
'light': 'bi-lightbulb-fill',
'fan': 'bi-fan',
'sensor': 'bi-thermometer-half',
'cover': 'bi-door-open',
}.get(device.kind, 'bi-plug') %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi {{ icon_cls }} fs-5 text-info"></i>
<span class="fw-bold">{{ device.name }}</span>
</div>
{% if device.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-5 text-muted">Device ID</dt>
<dd class="col-7 font-monospace text-break">{{ device.device_id }}</dd>
<dt class="col-5 text-muted">Category</dt>
<dd class="col-7">{{ device.category }} ({{ device.kind }})</dd>
{% if device.product_name %}
<dt class="col-5 text-muted">Product</dt>
<dd class="col-7">{{ device.product_name }}</dd>
{% endif %}
{% if device.last_seen %}
<dt class="col-5 text-muted">Last seen</dt>
<dd class="col-7">{{ device.last_seen.strftime('%Y-%m-%d %H:%M') }}</dd>
{% endif %}
</dl>
</div>
</div>
{# ── Controls ────────────────────────────────────────────────────── #}
{% if device.switch_dps %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header py-2"><strong>Controls</strong></div>
<div class="card-body d-flex flex-wrap gap-3">
{% for dp in device.switch_dps %}
{% set ch_on = device.status.get(dp, False) %}
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-3 py-2"
id="ctrl-dp-{{ dp }}">
<span class="text-muted small">
{% if device.num_channels == 1 %}Power{% else %}CH{{ loop.index }}{% endif %}
<span class="font-monospace opacity-50">({{ dp }})</span>
</span>
<button
class="btn btn-sm px-3 tuya-dp-btn {{ 'btn-info' if ch_on else 'btn-outline-info' }}"
data-url="{{ url_for('tuya.toggle_dp', board_id=board.id, device_id=device.device_id, dp_code=dp) }}"
data-dp="{{ dp }}"
>{{ 'ON' if ch_on else 'OFF' }}</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ── Full status ─────────────────────────────────────────────────── #}
{% if device.status %}
<div class="card bg-dark border-secondary mb-4">
<div class="card-header py-2 d-flex align-items-center justify-content-between">
<strong>Full Status</strong>
<small class="text-muted">All data points</small>
</div>
<div class="card-body p-0">
<table class="table table-dark table-sm table-striped mb-0">
<thead><tr><th>DP Code</th><th>Value</th></tr></thead>
<tbody>
{% for k, v in device.status.items() %}
{% set fv = v | tuya_dp(k, device.status) %}
<tr>
<td class="font-monospace text-muted">{{ k }}</td>
<td>
{% if fv is sameas true %}
<span class="badge bg-success">true</span>
{% elif fv is sameas false %}
<span class="badge bg-secondary">false</span>
{% else %}
{{ fv }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Rename ──────────────────────────────────────────────────────── #}
{% if current_user.is_admin() %}
<div class="card bg-dark border-secondary">
<div class="card-header py-2"><strong>Rename Device</strong></div>
<div class="card-body">
<form method="post" action="{{ url_for('tuya.rename_device', board_id=board.id, device_id=device.device_id) }}"
class="d-flex gap-2">
<input type="text" name="name" class="form-control form-control-sm"
value="{{ device.name }}" required style="max-width:300px">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>Rename
</button>
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
document.querySelectorAll('.tuya-dp-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const url = btn.dataset.url;
const dp = btn.dataset.dp;
btn.disabled = true;
fetch(url, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
})
.then(r => r.json())
.then(function(data) {
const wrap = document.getElementById('ctrl-dp-' + dp);
if (!wrap) return;
const b = wrap.querySelector('.tuya-dp-btn');
if (!b) return;
b.disabled = false;
if (data.state) {
b.textContent = 'ON';
b.classList.replace('btn-outline-info', 'btn-info');
} else {
b.textContent = 'OFF';
b.classList.replace('btn-info', 'btn-outline-info');
}
})
.catch(function() { btn.disabled = false; });
});
});
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}{% for cat, msg in messages %}{# consumed by parent template #}{% endfor %}{% endif %}
{% endwith %}
</script>
{% endblock %}

View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}{{ board.name }} Tuya Cloud Gateway{% endblock %}
{% block content %}
<div class="container-fluid py-3">
{# ── Page header ──────────────────────────────────────────────────── #}
<div class="d-flex align-items-center justify-content-between mb-4 gap-2 flex-wrap">
<div class="d-flex align-items-center gap-3">
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<div>
<h4 class="mb-0 fw-bold">
<i class="bi bi-cloud-fill text-info me-2"></i>{{ board.name }}
</h4>
<small class="text-muted">Tuya Cloud Gateway
{% if board.is_online %}
<span class="badge bg-success ms-2">Online</span>
{% else %}
<span class="badge bg-secondary ms-2">Offline</span>
{% endif %}
</small>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
{% if current_user.is_admin() %}
<form method="post" action="{{ url_for('tuya.sync_devices', board_id=board.id) }}">
<button class="btn btn-primary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Sync Devices
</button>
</form>
<a href="{{ url_for('tuya.auth_settings', board_id=board.id) }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-key me-1"></i>Tuya Settings
</a>
{% endif %}
</div>
</div>
{# ── Flash messages ───────────────────────────────────────────────── #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show py-2" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{# ── No token warning ─────────────────────────────────────────────── #}
{% if not has_token %}
<div class="alert alert-warning d-flex align-items-center gap-3" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
<div>
<strong>Tuya account not linked.</strong>
<a href="{{ url_for('tuya.auth_settings', board_id=board.id) }}" class="alert-link">
Scan a QR code with the Smart Life app
</a> to discover and control devices.
</div>
</div>
{% endif %}
{# ── Device grid ──────────────────────────────────────────────────── #}
{% if devices %}
<div class="row g-3">
{% for dev in devices %}
{% set icon_cls = {
'switch': 'bi-toggles',
'light': 'bi-lightbulb-fill',
'fan': 'bi-fan',
'sensor': 'bi-thermometer-half',
'cover': 'bi-door-open',
}.get(dev.kind, 'bi-plug') %}
<div class="col-12 col-md-6 col-xl-4" id="tuya-card-{{ dev.device_id }}">
<div class="card bg-dark border-secondary h-100">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi {{ icon_cls }} text-info"></i>
<span class="fw-semibold text-truncate" style="max-width:160px"
title="{{ dev.name }}">{{ dev.name }}</span>
</div>
<div class="d-flex align-items-center gap-2">
{% if dev.is_online %}
<span class="badge bg-success">Online</span>
{% else %}
<span class="badge bg-secondary">Offline</span>
{% endif %}
<a href="{{ url_for('tuya.device_detail', board_id=board.id, device_id=dev.device_id) }}"
class="btn btn-outline-secondary btn-sm px-2 py-0">
<i class="bi bi-sliders"></i>
</a>
</div>
</div>
<div class="card-body py-2">
<div class="text-muted small mb-2">
{{ dev.kind | capitalize }}
{% if dev.product_name %}
{{ dev.product_name }}
{% endif %}
</div>
{# ── Switch / light / fan toggles ─────────────────────────── #}
{% if dev.kind in ('switch', 'light', 'fan') and dev.switch_dps %}
<div class="d-flex flex-wrap gap-2">
{% for dp in dev.switch_dps %}
{% set ch_idx = loop.index0 %}
{% set ch_on = dev.status.get(dp, False) %}
<div class="d-flex align-items-center gap-2 border border-secondary rounded px-2 py-1"
id="tuya-dp-{{ dev.device_id }}-{{ dp }}">
<span class="small text-muted">
{% if dev.num_channels == 1 %}Power{% else %}CH{{ loop.index }}{% endif %}
</span>
<button
class="btn btn-sm px-2 py-0 tuya-dp-btn {{ 'btn-info' if ch_on else 'btn-outline-info' }}"
data-url="{{ url_for('tuya.toggle_dp', board_id=board.id, device_id=dev.device_id, dp_code=dp) }}"
data-device="{{ dev.device_id }}" data-dp="{{ dp }}"
title="Toggle {{ dp }}"
>{{ 'ON' if ch_on else 'OFF' }}</button>
</div>
{% endfor %}
</div>
{# ── Sensor show formatted live readings ────────────────── #}
{% elif dev.kind == 'sensor' %}
{# Hide config/threshold keys that clutter the card #}
{% set _skip_suffixes = ['_set', '_alarm', '_convert', '_mode'] %}
<dl class="row mb-0 small">
{% for k, v in dev.status.items() %}
{% set _hide = namespace(val=false) %}
{% for sfx in _skip_suffixes %}
{% if k.endswith(sfx) %}{% set _hide.val = true %}{% endif %}
{% endfor %}
{% if not _hide.val %}
{% set fv = v | tuya_dp(k, dev.status) %}
<dt class="col-6 text-muted text-truncate" title="{{ k }}">{{ k }}</dt>
<dd class="col-6 mb-1">
{% if fv is sameas true %}<span class="badge bg-success">on</span>
{% elif fv is sameas false %}<span class="badge bg-secondary">off</span>
{% else %}{{ fv }}{% endif %}
</dd>
{% endif %}
{% endfor %}
</dl>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-cloud-slash fs-1 d-block mb-3 opacity-50"></i>
<p>No Tuya devices found.</p>
{% if has_token %}
<form method="post" action="{{ url_for('tuya.sync_devices', board_id=board.id) }}" class="d-inline">
<button class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-repeat me-1"></i>Sync now
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
/* ── AJAX toggles ─────────────────────────────────────────────────────────── */
document.querySelectorAll('.tuya-dp-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const url = btn.dataset.url;
const device = btn.dataset.device;
const dp = btn.dataset.dp;
btn.disabled = true;
fetch(url, {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'},
})
.then(r => r.json())
.then(function(data) {
updateDpButton(device, dp, data.state);
})
.catch(function() { btn.disabled = false; });
});
});
function updateDpButton(deviceId, dp, state) {
const wrap = document.getElementById('tuya-dp-' + deviceId + '-' + dp);
if (!wrap) return;
const btn = wrap.querySelector('.tuya-dp-btn');
if (!btn) return;
btn.disabled = false;
if (state) {
btn.textContent = 'ON';
btn.classList.replace('btn-outline-info', 'btn-info');
} else {
btn.textContent = 'OFF';
btn.classList.replace('btn-info', 'btn-outline-info');
}
}
/* ── Socket.IO live updates ───────────────────────────────────────────────── */
if (typeof io !== 'undefined') {
const socket = io();
socket.on('tuya_update', function(data) {
if (data.board_id !== {{ board.id }}) return;
updateDpButton(data.device_id, data.dp_code, data.state);
});
}
</script>
{% endblock %}

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -12,3 +12,5 @@ WTForms==3.1.2
SQLAlchemy==2.0.31
greenlet>=3.1.1
pycryptodome==3.23.0
cryptography>=42.0.0
tuya-device-sharing-sdk>=0.2.9