diff --git a/app/__init__.py b/app/__init__.py index c7ab2ea..d71c42b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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) diff --git a/app/drivers/tuya_cloud/__init__.py b/app/drivers/tuya_cloud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/drivers/tuya_cloud/driver.py b/app/drivers/tuya_cloud/driver.py new file mode 100644 index 0000000..999d175 --- /dev/null +++ b/app/drivers/tuya_cloud/driver.py @@ -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() diff --git a/app/drivers/tuya_cloud/manifest.json b/app/drivers/tuya_cloud/manifest.json new file mode 100644 index 0000000..4694d41 --- /dev/null +++ b/app/drivers/tuya_cloud/manifest.json @@ -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" +} diff --git a/app/models/board.py b/app/models/board.py index 8004aa6..1d3cae1 100644 --- a/app/models/board.py +++ b/app/models/board.py @@ -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 diff --git a/app/models/tuya_device.py b/app/models/tuya_device.py new file mode 100644 index 0000000..4b6e292 --- /dev/null +++ b/app/models/tuya_device.py @@ -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" diff --git a/app/routes/boards.py b/app/routes/boards.py index bbc7e6b..dccdddd 100644 --- a/app/routes/boards.py +++ b/app/routes/boards.py @@ -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()) diff --git a/app/routes/layouts.py b/app/routes/layouts.py index c152d9a..9e27fbc 100644 --- a/app/routes/layouts.py +++ b/app/routes/layouts.py @@ -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( diff --git a/app/routes/tuya.py b/app/routes/tuya.py new file mode 100644 index 0000000..60d4c2c --- /dev/null +++ b/app/routes/tuya.py @@ -0,0 +1,250 @@ +"""Tuya Cloud Gateway routes. + +URL structure: + GET /tuya/ – gateway overview + GET /tuya//auth – auth settings page + POST /tuya//auth/qr – (AJAX) generate QR code + GET /tuya//auth/poll – (AJAX) poll login result + POST /tuya//auth/save – (AJAX) persist tokens + POST /tuya//sync – sync devices from cloud + GET /tuya//device/ – device detail + POST /tuya//device//dp//toggle – toggle a DP + POST /tuya//device//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("/") +@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("//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("//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("//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("//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("//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("//device/") +@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("//device//dp//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("//device//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)) diff --git a/app/static/js/layout_builder.js b/app/static/js/layout_builder.js index c029f4a..e29e46f 100644 --- a/app/static/js/layout_builder.js +++ b/app/static/js/layout_builder.js @@ -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 = ` + + + ${escHtml(tc.name)} + ${escHtml(tc.kind || 'switch')}`; + + 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; diff --git a/app/templates/boards/add.html b/app/templates/boards/add.html index 40de3b8..caa1385 100644 --- a/app/templates/boards/add.html +++ b/app/templates/boards/add.html @@ -2,99 +2,340 @@ {% block title %}Add Board – Location Management{% endblock %} {% block content %} -