From 5340f88ffe347fa75b54c4e94c70da196ab11972 Mon Sep 17 00:00:00 2001 From: scheianu Date: Mon, 13 Apr 2026 21:35:17 +0300 Subject: [PATCH] Add NFC enable/disable support; update devices, Sonoff, and Tuya --- app/__init__.py | 61 ++++ .../olimex_esp32_c6_evb_pn532/driver.py | 24 ++ .../olimex_esp32_c6_evb_pn532/manifest.json | 3 +- app/drivers/sonoff_ewelink/driver.py | 29 +- app/models/device.py | 111 ++++++- app/models/sonoff_device.py | 19 +- app/routes/boards.py | 24 ++ app/routes/devices.py | 168 ++++++++++- app/routes/tuya.py | 8 +- app/templates/boards/nfc.html | 43 +++ app/templates/devices/_card.html | 28 +- app/templates/devices/edit.html | 279 +++++++++++++----- app/templates/devices/list.html | 212 +++++++++---- app/templates/tuya/auth_settings.html | 7 +- config.py | 2 +- 15 files changed, 843 insertions(+), 175 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 4e1f998..b8a9643 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -84,6 +84,8 @@ def create_app(config_name: str = "default") -> Flask: _add_entities_column(app) _add_config_json_column(app) _migrate_board_types(app) + _add_device_hardware_id_column(app) + _add_device_app_id_column(app) return app @@ -144,3 +146,62 @@ def _seed_admin(app: Flask) -> None: db.session.add(admin) db.session.commit() app.logger.info("Default admin user created (username=admin password=admin)") + + +def _add_device_app_id_column(app: Flask) -> None: + """Add app_id column to devices table and backfill existing rows.""" + from sqlalchemy import text + with app.app_context(): + try: + db.session.execute(text("ALTER TABLE devices ADD COLUMN app_id TEXT")) + db.session.commit() + app.logger.info("Added app_id column to devices table") + except Exception: + db.session.rollback() # Column already exists — safe to ignore + + # Backfill any rows that have no app_id yet + from app.models.device import Device + unset = Device.query.filter(Device.app_id.is_(None)).all() + for d in unset: + d.app_id = Device.generate_app_id(d.name, exclude_id=d.id) + if unset: + db.session.commit() + app.logger.info("Backfilled app_id for %d device(s)", len(unset)) + + +def _add_device_hardware_id_column(app: Flask) -> None: + """Add hardware_device_id column and backfill from existing entity_num bindings.""" + from sqlalchemy import text + with app.app_context(): + try: + db.session.execute(text("ALTER TABLE devices ADD COLUMN hardware_device_id TEXT")) + db.session.commit() + app.logger.info("Added hardware_device_id column to devices table") + except Exception: + db.session.rollback() # Column already exists — safe to ignore + + # Backfill: resolve entity_num → actual hardware device_id string + from app.models.device import Device + from app.models.sonoff_device import SonoffDevice + from app.models.tuya_device import TuyaDevice + unset = Device.query.filter( + Device.hardware_device_id.is_(None), + Device.entity_num.isnot(None), + Device.entity_type.in_(["sonoff", "tuya"]), + ).all() + for d in unset: + hw_id = None + sd_or_td_id = d.entity_num // 100 + if d.entity_type == "sonoff": + obj = db.session.get(SonoffDevice, sd_or_td_id) + if obj: + hw_id = obj.device_id + elif d.entity_type == "tuya": + obj = db.session.get(TuyaDevice, sd_or_td_id) + if obj: + hw_id = obj.device_id + if hw_id: + d.hardware_device_id = hw_id + if unset: + db.session.commit() + app.logger.info("Backfilled hardware_device_id for %d device(s)", len(unset)) diff --git a/app/drivers/olimex_esp32_c6_evb_pn532/driver.py b/app/drivers/olimex_esp32_c6_evb_pn532/driver.py index 1e1fa6f..7c03440 100644 --- a/app/drivers/olimex_esp32_c6_evb_pn532/driver.py +++ b/app/drivers/olimex_esp32_c6_evb_pn532/driver.py @@ -24,6 +24,7 @@ GET /nfc/status → {"initialized": bool, "c "pulse_ms": int} GET /nfc/config → {"auth_uid": str, "relay_num": int, "pulse_ms": int} POST /nfc/config?auth_uid=&relay=&pulse_ms= → {"status": "ok", ...} +POST /nfc/enable?state=0|1 → {"status": "ok", "nfc_enabled": bool} Webhook (board → server) ------------------------ @@ -213,3 +214,26 @@ class OlimexESP32C6EVBPn532Driver(BoardDriver): else: logger.warning("NFC config push failed for board '%s'", board.name) return result is not None + + def set_nfc_enabled(self, board: "Board", enabled: bool) -> bool: + """Enable or disable the NFC/Mifare access-control module on the board. + + When disabled the board stops polling the PN532, resets any active + relay opened by a card, and persists the setting to NVS so it + survives power cycles. + + enabled: True = module on, False = module off. + """ + state = 1 if enabled else 0 + url = f"{board.base_url}/nfc/enable?state={state}" + result = _post(url, _auth(board, "POST", url)) + if result is not None: + logger.info( + "NFC module on board '%s' set to: %s", + board.name, "ENABLED" if enabled else "DISABLED", + ) + else: + logger.warning( + "Failed to set NFC module state on board '%s'", board.name + ) + return result is not None diff --git a/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json b/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json index 7e1d44f..fc9433a 100644 --- a/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json +++ b/app/drivers/olimex_esp32_c6_evb_pn532/manifest.json @@ -23,6 +23,7 @@ "register": "POST /register?callback_url={url}", "nfc_status": "GET /nfc/status", "nfc_config": "GET /nfc/config", - "nfc_config_set": "POST /nfc/config?auth_uid={uid}&relay={n}&pulse_ms={ms}" + "nfc_config_set": "POST /nfc/config?auth_uid={uid}&relay={n}&pulse_ms={ms}", + "nfc_enable": "POST /nfc/enable?state={0|1}" } } diff --git a/app/drivers/sonoff_ewelink/driver.py b/app/drivers/sonoff_ewelink/driver.py index 10f0575..f1000b6 100644 --- a/app/drivers/sonoff_ewelink/driver.py +++ b/app/drivers/sonoff_ewelink/driver.py @@ -209,13 +209,20 @@ class SonoffEweLinkDriver(BoardDriver): switch_val = "on" if state else "off" - # Build params dict - if dev.num_channels == 1: - params = {"switch": switch_val} - command = "switch" - else: + # Build params dict — detect format from stored params rather than + # relying only on num_channels, because some devices (e.g. UIID 138) + # report num_channels=1 but actually use the "switches" array format. + stored_params = dev.params + uses_switches_fmt = ( + dev.num_channels > 1 + or "switches" in stored_params + ) + if uses_switches_fmt: params = {"switches": [{"outlet": channel, "switch": switch_val}]} command = "switches" + else: + params = {"switch": switch_val} + command = "switch" # ── Try LAN first ─────────────────────────────────────────────────── if dev.ip_address: @@ -259,9 +266,8 @@ class SonoffEweLinkDriver(BoardDriver): """Update the SonoffDevice params in-memory after a successful command.""" p = dev.params switch_val = "on" if state else "off" - if dev.num_channels == 1: - p["switch"] = switch_val - else: + uses_switches_fmt = dev.num_channels > 1 or "switches" in p + if uses_switches_fmt: switches = p.get("switches", []) updated = False for s in switches: @@ -272,6 +278,8 @@ class SonoffEweLinkDriver(BoardDriver): if not updated: switches.append({"outlet": channel, "switch": switch_val}) p["switches"] = switches + else: + p["switch"] = switch_val dev.params = p @staticmethod @@ -284,11 +292,12 @@ class SonoffEweLinkDriver(BoardDriver): """ import requests as req from .ewelink_api import API, APPID - import time headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID} + # v2 /device/thing/status requires "type" (1 = device) and "id", not "deviceid" payload = { - "deviceid": device_id, + "type": 1, + "id": device_id, "params": params, } url = f"{API[region]}/v2/device/thing/status" diff --git a/app/models/device.py b/app/models/device.py index 96e0d0e..be910c4 100644 --- a/app/models/device.py +++ b/app/models/device.py @@ -1,12 +1,17 @@ """User-defined device abstraction. -A Device is a human-friendly alias for a board relay (controllable output) or -a board digital input (sensor/trigger) that the user wants to expose as a named -entity — e.g. "Outdoor Light 1 (Courtyard)" mapped to Board "Garage" / Relay 4. +A Device is a virtual application entity — a named, typed unit (light, switch, +pump, sensor…) that *optionally* maps to a physical board channel. -Devices provide an intermediate, named layer so they can later be placed on -Layout pages as interactive widgets without the UI needing raw board/relay IDs. +The two-step model: + 1. Create the device with name/description/type → it gets a stable app_id + 2. Optionally bind it to a board relay or input → enables live control/state + +app_id is a URL-safe slug (e.g. "outdoor_light_courtyard") that the rest of the +application — layouts, automations, API — uses as a stable programmatic handle. +When a device is bound, toggling it controls the physical hardware. """ +import re from datetime import datetime from app import db @@ -16,6 +21,12 @@ class Device(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), nullable=False) + + # Stable application identifier — URL-safe slug, auto-generated from name. + # Used by layouts, the JSON API, and automations to reference a device + # without coupling code to numeric DB IDs. + app_id = db.Column(db.String(64), unique=True, nullable=True, index=True) + description = db.Column(db.String(256), nullable=True) area = db.Column(db.String(64), nullable=True) # e.g. "Courtyard", "Living Room" @@ -25,23 +36,53 @@ class Device(db.Model): # Bootstrap-Icons class override; None → use type default icon = db.Column(db.String(64), nullable=True) - # ── Source entity on a board ───────────────────────────────────────────── + # ── Optional source entity on a board ──────────────────────────────────── + # Leave all three NULL for a virtual/unbound device. board_id = db.Column( db.Integer, db.ForeignKey("boards.id", ondelete="SET NULL"), nullable=True ) - entity_type = db.Column(db.String(16), nullable=True) # "relay" | "input" - entity_num = db.Column(db.Integer, nullable=True) # 1-based relay/input index + entity_type = db.Column(db.String(16), nullable=True) # "relay" | "input" | "tuya" | "sonoff" + entity_num = db.Column(db.Integer, nullable=True) # encoded: sd_id*100+ch for gateways, else 1-based + + # Direct reference to the hardware device identifier: + # For Sonoff: SonoffDevice.device_id (e.g. "10024c298a") + # For Tuya: TuyaDevice.device_id + # For relay/input: None (board relay/input index is sufficient) + hardware_device_id = db.Column(db.String(64), nullable=True, index=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) + # ── app_id helpers ──────────────────────────────────────────────────────── + + @staticmethod + def _make_slug(name: str) -> str: + """Return a lowercase alphanumeric slug from *name* (max 60 chars).""" + slug = re.sub(r"[^a-z0-9]+", "_", name.lower().strip()) + return slug.strip("_")[:60] or "device" + + @classmethod + def generate_app_id(cls, name: str, exclude_id: int | None = None) -> str: + """Return a unique app_id for *name*, appending _2, _3… on conflicts.""" + base = cls._make_slug(name) + slug = base + n = 2 + while True: + q = cls.query.filter_by(app_id=slug) + if exclude_id is not None: + q = q.filter(cls.id != exclude_id) + if q.first() is None: + return slug + slug = f"{base}_{n}" + n += 1 + # ── relationships ──────────────────────────────────────────────────────── board = db.relationship("Board", back_populates="devices") # ── helpers ────────────────────────────────────────────────────────────── @property def is_controllable(self) -> bool: - """True when the source entity is a relay (can be toggled ON/OFF).""" - return self.entity_type == "relay" + """True when the source entity is a relay or Tuya/Sonoff switch (can be toggled ON/OFF).""" + return self.entity_type in ("relay", "tuya", "sonoff") @property def effective_icon(self) -> str: @@ -49,7 +90,7 @@ class Device(db.Model): from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES if self.icon: return self.icon - if self.entity_type == "relay": + if self.entity_type in ("relay", "tuya", "sonoff"): return RELAY_ENTITY_TYPES.get( self.device_class, RELAY_ENTITY_TYPES["switch"] )["icon"] @@ -69,6 +110,25 @@ class Device(db.Model): if self.entity_type == "input": raw = self.board.input_states.get(f"input_{self.entity_num}", True) return not raw # NC contact: raw True = resting → False = idle + if self.entity_type == "tuya": + from app.models.tuya_device import TuyaDevice + td_id = self.entity_num // 100 + ch_idx = (self.entity_num % 100) - 1 + td = TuyaDevice.query.get(td_id) + if td is None: + return None + dps = td.switch_dps + if ch_idx >= len(dps): + return None + return bool(td.status.get(dps[ch_idx], False)) + if self.entity_type == "sonoff": + from app.models.sonoff_device import SonoffDevice + sd_id = self.entity_num // 100 + channel = self.entity_num % 100 + sd = SonoffDevice.query.get(sd_id) + if sd is None: + return None + return sd.get_channel_state(channel) return None @property @@ -78,7 +138,7 @@ class Device(db.Model): state = self.current_state if state is None: return "Unknown" - if self.entity_type == "relay": + if self.entity_type in ("relay", "tuya", "sonoff"): tdef = RELAY_ENTITY_TYPES.get(self.device_class, RELAY_ENTITY_TYPES["switch"]) return tdef["on"][1] if state else tdef["off"][1] if self.entity_type == "input": @@ -93,7 +153,7 @@ class Device(db.Model): state = self.current_state if state is None: return "secondary" - if self.entity_type == "relay": + if self.entity_type in ("relay", "tuya", "sonoff"): tdef = RELAY_ENTITY_TYPES.get(self.device_class, RELAY_ENTITY_TYPES["switch"]) return tdef["on"][0] if state else tdef["off"][0] if self.entity_type == "input": @@ -101,5 +161,30 @@ class Device(db.Model): return tdef["active"][0] if state else tdef["idle"][0] return "secondary" + @property + def channel_label(self) -> str: + """Human-readable board channel label for UI badges.""" + if self.entity_type == "sonoff" and self.entity_num is not None: + from app.models.sonoff_device import SonoffDevice + sd_id = self.entity_num // 100 + ch_num = self.entity_num % 100 + sd = SonoffDevice.query.get(sd_id) + if sd: + return sd.name if sd.num_channels <= 1 else f"{sd.name} · Ch {ch_num + 1}" + return f"Sonoff #{self.entity_num}" + if self.entity_type == "tuya" and self.entity_num: + from app.models.tuya_device import TuyaDevice + td_id = self.entity_num // 100 + ch_num = self.entity_num % 100 + td = TuyaDevice.query.get(td_id) + if td: + return td.name if td.num_channels <= 1 else f"{td.name} · Ch {ch_num}" + return f"Tuya #{self.entity_num}" + if self.entity_type == "relay": + return f"Relay {self.entity_num}" + if self.entity_type == "input": + return f"Input {self.entity_num}" + return "" + def __repr__(self): return f"" diff --git a/app/models/sonoff_device.py b/app/models/sonoff_device.py index c4519bf..fb5b4d1 100644 --- a/app/models/sonoff_device.py +++ b/app/models/sonoff_device.py @@ -109,17 +109,18 @@ class SonoffDevice(db.Model): def get_channel_state(self, channel: int = 0) -> bool: """Return True if channel is ON. - For single-channel devices uses 'switch' param. - For multi-channel uses 'switches' array with outlet index. + Detects format from stored params: if 'switches' key is present (or + num_channels > 1) uses the switches-array format; otherwise uses 'switch'. """ p = self.params - if self.num_channels == 1: - return p.get("switch") == "on" - switches = p.get("switches", []) - for s in switches: - if s.get("outlet") == channel: - return s.get("switch") == "on" - return False + uses_switches_fmt = self.num_channels > 1 or "switches" in p + if uses_switches_fmt: + switches = p.get("switches", []) + for s in switches: + if s.get("outlet") == channel: + return s.get("switch") == "on" + return False + return p.get("switch") == "on" def all_channel_states(self) -> list[dict]: """Return [{channel, label, state}, ...] for every channel.""" diff --git a/app/routes/boards.py b/app/routes/boards.py index 71c5e99..95a3508 100644 --- a/app/routes/boards.py +++ b/app/routes/boards.py @@ -380,3 +380,27 @@ def nfc_enroll(board_id: int): else: flash("Card read OK but failed to push config to board.", "danger") return redirect(url_for("boards.nfc_management", board_id=board_id)) + + +@boards_bp.route("//nfc/enable", methods=["POST"]) +@login_required +def nfc_enable(board_id: int): + """Enable or disable the NFC/Mifare access-control module on the board.""" + if not current_user.is_admin(): + abort(403) + board = db.get_or_404(Board, board_id) + if board.board_type != _NFC_DRIVER_ID: + abort(404) + driver = registry.get(_NFC_DRIVER_ID) + if not driver: + flash("NFC driver not available.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) + + enabled = request.form.get("enabled", "0") == "1" + ok = driver.set_nfc_enabled(board, enabled) + if ok: + state_label = "enabled" if enabled else "disabled" + flash(f"NFC access control module {state_label}.", "success") + else: + flash("Failed to change NFC module state — board unreachable.", "danger") + return redirect(url_for("boards.nfc_management", board_id=board_id)) diff --git a/app/routes/devices.py b/app/routes/devices.py index 897a0b0..db29248 100644 --- a/app/routes/devices.py +++ b/app/routes/devices.py @@ -7,14 +7,15 @@ import json from flask import (Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify) from flask_login import login_required, current_user -from flask import current_app from app import db from app.models.board import Board from app.models.device import Device +from app.models.tuya_device import TuyaDevice +from app.models.sonoff_device import SonoffDevice from app.models.entity_types import (RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES, ICON_PALETTE) -from app.services.board_service import set_relay, poll_board +from app.services.board_service import set_relay devices_bp = Blueprint("devices", __name__) @@ -61,6 +62,7 @@ def add_device(): board_id = request.form.get("board_id") or None entity_type = request.form.get("entity_type") or None entity_num = request.form.get("entity_num") or None + hardware_device_id = request.form.get("hardware_device_id") or None device = Device( name = name, @@ -71,11 +73,13 @@ def add_device(): board_id = int(board_id) if board_id else None, entity_type = entity_type, entity_num = int(entity_num) if entity_num else None, + hardware_device_id = hardware_device_id, ) + device.app_id = Device.generate_app_id(name) db.session.add(device) db.session.commit() - flash(f"Device '{name}' created successfully.", "success") - return redirect(url_for("devices.list_devices")) + flash(f"Device '{name}' created. You can now optionally bind it to hardware below.", "success") + return redirect(url_for("devices.edit_device", device_id=device.id)) return render_template( "devices/edit.html", @@ -111,6 +115,7 @@ def edit_device(device_id: int): board_id = request.form.get("board_id") or None entity_type = request.form.get("entity_type") or None entity_num = request.form.get("entity_num") or None + hardware_device_id = request.form.get("hardware_device_id") or None device.name = name device.description = request.form.get("description", "").strip() or None @@ -120,6 +125,19 @@ def edit_device(device_id: int): device.board_id = int(board_id) if board_id else None device.entity_type = entity_type device.entity_num = int(entity_num) if entity_num else None + device.hardware_device_id = hardware_device_id + + # Regenerate app_id if name changed and app_id was based on the old name + custom_app_id = request.form.get("app_id", "").strip() + if custom_app_id: + # Accept a manually set app_id only if unique + existing = Device.query.filter( + Device.app_id == custom_app_id, Device.id != device.id + ).first() + if not existing: + device.app_id = custom_app_id + elif device.app_id is None: + device.app_id = Device.generate_app_id(name, exclude_id=device.id) db.session.commit() flash(f"Device '{name}' updated.", "success") @@ -160,15 +178,75 @@ def toggle_device(device_id: int): if not device.board: return jsonify({"ok": False, "error": "No board linked."}), 400 - # Determine new state (toggle) - current = device.board.relay_states.get(f"relay_{device.entity_num}", False) + # ── Sonoff eWeLink toggle ────────────────────────────────────────────────── + if device.entity_type == "sonoff": + from app.drivers.registry import registry + drv = registry.get("sonoff_ewelink") + if not drv: + return jsonify({"ok": False, "error": "Sonoff driver not available."}), 500 + sd_id = device.entity_num // 100 + channel = device.entity_num % 100 + sd = db.session.get(SonoffDevice, sd_id) + if not sd: + return jsonify({"ok": False, "error": "Sonoff device not found."}), 404 + if not sd.is_online: + return jsonify({"ok": False, "error": "Device is offline."}) + current_state = sd.get_channel_state(channel) + ok = drv.set_device_channel(device.board, sd.device_id, channel, not current_state) + updated = db.session.get(Device, device_id) + error_msg = None if ok else "Cloud control failed. Device may be offline." + return jsonify({ + "ok": ok, + "state": updated.current_state, + "state_label": updated.state_label, + "state_color": updated.state_color, + "error": error_msg, + }) + + # ── Tuya Cloud toggle ──────────────────────────────────────────────────── + if device.entity_type == "tuya": + from app.drivers.registry import registry + drv = registry.get("tuya_cloud") + if not drv: + return jsonify({"ok": False, "error": "Tuya driver not available."}), 500 + td_id = device.entity_num // 100 + channel = (device.entity_num % 100) - 1 # 0-based index + td = db.session.get(TuyaDevice, td_id) + if not td: + return jsonify({"ok": False, "error": "Tuya device not found."}), 404 + dps = td.switch_dps + if channel >= len(dps): + return jsonify({"ok": False, "error": "Invalid channel index."}), 400 + dp_code = dps[channel] + ok = drv.toggle_dp(device.board, td.device_id, dp_code) + updated = db.session.get(Device, device_id) + return jsonify({ + "ok": ok, + "state": updated.current_state, + "state_label": updated.state_label, + "state_color": updated.state_color, + }) + + # ── Generic relay toggle ───────────────────────────────────────────────── + # Optimistically write the new state to DB first (same pattern as boards.py + # toggle_relay_view) so the device card reflects the change immediately. + # poll_board() skips relay state updates for online boards by design, so + # without this the card would always show the stale pre-toggle state. + board = device.board + states = board.relay_states + current = states.get(f"relay_{device.entity_num}", False) new_state = not current + states[f"relay_{device.entity_num}"] = new_state + board.relay_states = states + db.session.commit() - app = current_app._get_current_object() - ok = set_relay(device.board, device.entity_num, new_state) + ok = set_relay(board, device.entity_num, new_state) + if not ok: + # Hardware command failed — roll back the optimistic state + states[f"relay_{device.entity_num}"] = current + board.relay_states = states + db.session.commit() - # Re-read updated relay state - poll_board(app, device.board_id) updated_device = db.session.get(Device, device_id) return jsonify({ @@ -186,6 +264,76 @@ def toggle_device(device_id: int): def board_entities(board_id: int): """Return relay and input entity info for the given board as JSON.""" board = db.get_or_404(Board, board_id) + + # ── Tuya Cloud boards expose TuyaDevice rows, not relay/input channels ── + if board.board_type == "tuya_cloud": + from app.drivers.tuya_cloud.driver import KIND_ICON + tuya_devs = ( + TuyaDevice.query.filter_by(board_id=board_id) + .order_by(TuyaDevice.name) + .all() + ) + relays = [] + for td in tuya_devs: + dps = td.switch_dps + for ch_idx, dp_code in enumerate(dps): + num = td.id * 100 + (ch_idx + 1) + state = bool(td.status.get(dp_code, False)) + name = td.name if len(dps) == 1 else f"{td.name} – Ch {ch_idx + 1}" + relays.append({ + "num": num, + "name": name, + "type": td.kind, + "icon": td.kind_icon_cls, + "state": state, + "state_label": "ON" if state else "OFF", + "on_color": "success", + "off_color": "secondary", + "entity_type": "tuya", + "device_id": td.device_id, + }) + return jsonify({"relays": relays, "inputs": [], "board_type": "tuya_cloud"}) + + # ── Sonoff eWeLink boards expose SonoffDevice rows ───────────────────────── + if board.board_type == "sonoff_ewelink": + from app.models.sonoff_device import UIID_INFO + KIND_ICON_SONOFF = { + "switch": "bi-toggles", + "light": "bi-lightbulb-fill", + "fan": "bi-fan", + "sensor": "bi-thermometer-half", + } + sonoff_devs = ( + SonoffDevice.query.filter_by(board_id=board_id) + .order_by(SonoffDevice.name) + .all() + ) + relays = [] + for sd in sonoff_devs: + uiid_meta = UIID_INFO.get(sd.uiid, {}) + kind = uiid_meta.get("kind", "switch") + if kind not in ("switch", "light", "fan"): + continue # sensors / remotes are not controllable + icon = KIND_ICON_SONOFF.get(kind, "bi-toggles") + for ch in range(sd.num_channels): + num = sd.id * 100 + ch + state = sd.get_channel_state(ch) + name = sd.name if sd.num_channels == 1 else f"{sd.name} – Ch {ch + 1}" + relays.append({ + "num": num, + "name": name, + "type": kind, + "icon": icon, + "state": state, + "state_label": "ON" if state else "OFF", + "on_color": "success", + "off_color": "secondary", + "entity_type": "sonoff", + "device_id": sd.device_id, + }) + return jsonify({"relays": relays, "inputs": [], "board_type": "sonoff_ewelink"}) + + # ── Generic relay/input boards ────────────────────────────────────────── relays = [] for n in range(1, board.num_relays + 1): e = board.get_relay_entity(n) diff --git a/app/routes/tuya.py b/app/routes/tuya.py index 60d4c2c..6d1fe1a 100644 --- a/app/routes/tuya.py +++ b/app/routes/tuya.py @@ -11,6 +11,7 @@ URL structure: POST /tuya//device//dp//toggle – toggle a DP POST /tuya//device//rename – rename a device """ +import logging from flask import ( Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for, @@ -25,6 +26,8 @@ from app.drivers.tuya_cloud.driver import ( TUYA_CLIENT_ID, TUYA_SCHEMA, category_kind, KIND_ICON, ) +logger = logging.getLogger(__name__) + tuya_bp = Blueprint("tuya", __name__, url_prefix="/tuya") @@ -101,7 +104,10 @@ def generate_qr(board_id: int): 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 + err_msg = response.get("msg", "QR generation failed") + logger.error("Tuya qr_code() failed for board %s: %s | full response: %s", + board_id, err_msg, response) + return jsonify({"ok": False, "error": err_msg}), 400 qr_token = response["result"]["qrcode"] # The URI that Smart Life / Tuya Smart app decodes from the QR: diff --git a/app/templates/boards/nfc.html b/app/templates/boards/nfc.html index 6101aae..3205ff7 100644 --- a/app/templates/boards/nfc.html +++ b/app/templates/boards/nfc.html @@ -87,6 +87,14 @@
+
Module state
+
+ {% if nfc.get('nfc_enabled') %} + Enabled + {% else %} + Disabled + {% endif %} +
Trigger relay
Relay {{ nfc.get('relay_num', 1) }}
Absence timeout
@@ -101,6 +109,34 @@
{% if current_user.is_admin() %} + +
+
+ Mifare / NFC Module +
+
+
+
Access control module
+
When disabled the board stops polling the PN532 and will not open any relay on card presentation. The setting persists across power cycles.
+
+
+ {% if nfc.get('nfc_enabled') %} + + + {% else %} + + + {% endif %} +
+
+
+
@@ -252,6 +288,13 @@ function loadStatus() { if (relayDisp) relayDisp.textContent = 'Relay ' + (d.relay_num || 1); const pulseDisp = document.getElementById('pulse-display'); if (pulseDisp) pulseDisp.textContent = (d.pulse_ms || 3000) + ' ms'; + // module enabled/disabled summary in left card + const modDd = document.getElementById('module-state-dd'); + if (modDd) { + modDd.innerHTML = d.nfc_enabled + ? 'Enabled' + : 'Disabled'; + } }) .catch(() => {}) .finally(() => { diff --git a/app/templates/devices/_card.html b/app/templates/devices/_card.html index e434273..9f3ff52 100644 --- a/app/templates/devices/_card.html +++ b/app/templates/devices/_card.html @@ -1,10 +1,12 @@ {% set state = device.current_state %} {% set controllable = device.is_controllable %} +{% set is_bound = device.board is not none %}
@@ -14,7 +16,7 @@ style="width:56px;height:56px;background:var(--bs-secondary-bg)"> + {% if controllable and is_bound and state %}color:var(--bs-{{ device.state_color }}){% endif %}">
{{ device.name }}
@@ -30,13 +32,23 @@ {{ device.device_class | capitalize }} - {% if device.board %} + {% if is_bound %} {{ device.board.name }} - / {{ device.entity_type | capitalize }} {{ device.entity_num }} + {% if device.channel_label %}· {{ device.channel_label }}{% endif %} {% else %} - No board + + Unbound + + {% endif %} + {% if device.app_id %} + + {{ device.app_id }} + {% endif %}
@@ -45,10 +57,10 @@
- {{ device.state_label }} + {% if not is_bound %}Virtual{% else %}{{ device.state_label }}{% endif %}
- {% if controllable and device.board %} + {% if controllable and is_bound %} + {% endif %} +
+
+ Stable identifier for layouts & automations. Leave blank to keep current or auto-generate. +
+
+
+ placeholder="e.g. Controls the outdoor courtyard lighting" maxlength="256" />
@@ -61,12 +103,16 @@
+
+ + +
-
Device Type
-

Sets default icon, state labels and colours. Auto-filled when you pick a channel.

+
Device Type
+

Defines default icon, state labels and colours.

@@ -103,7 +149,7 @@
+ style="width:58px;height:58px;background:var(--bs-secondary-bg)">
@@ -132,21 +178,30 @@
- -
-
-
-
- Board & Channel Binding -
-

- Select a board, then click the relay or sensor you want to bind to this device. - Toggling the device later will directly control the physical hardware. -

+
+ + +
+ + Hardware Binding + + Optional + Link to a physical relay, sensor, Tuya or Sonoff channel +
+ +
+
+

+ Select a board, then click a channel to bind this device to physical hardware. + Leave unbound to use the device as a virtual entity in layouts and automations. + Once bound, the device can be toggled ON/OFF and shows live state. +

-
- +
+ + +
+
+ +{# ── Area groups ──────────────────────────────────────────────────────────── #} +{% for area in named_areas %} +
+ {{ area }} +
+
+ {% for device in devices %} + {% if device.area == area %} +
+ {% include "devices/_card.html" %} +
+ {% endif %} + {% endfor %} +
+{% endfor %} + +{# ── Unbound / no-area devices ─────────────────────────────────────────────── #} {% set ungrouped = [] %} {% for d in devices %} {% if not d.area or d.area == '' %} @@ -43,30 +93,18 @@ {% endif %} {% endfor %} -{% for area in named_areas %} -
- {{ area }} -
-
- {% for device in devices %} - {% if device.area == area %} -
- {% include "devices/_card.html" %} -
- {% endif %} - {% endfor %} -
-{% endfor %} - {% if ungrouped %} {% if named_areas %} -
+
Other
{% endif %}
{% for device in ungrouped %} -
+
{% include "devices/_card.html" %}
{% endfor %} @@ -88,6 +126,7 @@ {% block scripts %} {% endblock %} diff --git a/app/templates/tuya/auth_settings.html b/app/templates/tuya/auth_settings.html index 3078ecb..2bca2cc 100644 --- a/app/templates/tuya/auth_settings.html +++ b/app/templates/tuya/auth_settings.html @@ -65,6 +65,8 @@ Generate QR Code +
+

@@ -114,6 +116,7 @@ btnCancel.addEventListener('click', cancelQr); function startQr() { + document.getElementById('qr-error-area').innerHTML = ''; const code = inputCode.value.trim(); if (!code) { inputCode.focus(); @@ -205,10 +208,12 @@ } function showError(msg) { + const area = document.getElementById('qr-error-area'); + area.innerHTML = ''; const el = document.createElement('div'); el.className = 'alert alert-danger py-2 mt-2'; el.textContent = msg; - qrCanvas.after(el); + area.appendChild(el); } })(); diff --git a/config.py b/config.py index 3122668..40022f1 100644 --- a/config.py +++ b/config.py @@ -15,7 +15,7 @@ class Config: ) SQLALCHEMY_TRACK_MODIFICATIONS = False # How often (seconds) the fast poller updates ONLINE boards - BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 10)) + BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 60)) # How often (seconds) offline boards are rechecked to see if they came back OFFLINE_RECHECK_INTERVAL = int(os.environ.get("OFFLINE_RECHECK_INTERVAL", 60)) # Base URL this server is reachable at (boards will POST webhooks here)