Add NFC enable/disable support; update devices, Sonoff, and Tuya

This commit is contained in:
2026-04-13 21:35:17 +03:00
parent 86bfecca26
commit 5340f88ffe
15 changed files with 843 additions and 175 deletions

View File

@@ -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))

View File

@@ -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

View File

@@ -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}"
}
}

View File

@@ -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"

View File

@@ -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"<Device {self.name!r} id={self.id}>"

View File

@@ -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."""

View File

@@ -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("/<int:board_id>/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))

View File

@@ -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)

View File

@@ -11,6 +11,7 @@ URL structure:
POST /tuya/<board_id>/device/<device_id>/dp/<dp>/toggle toggle a DP
POST /tuya/<board_id>/device/<device_id>/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:

View File

@@ -87,6 +87,14 @@
<!-- Current relay & timeout -->
<dl class="row mb-0 small">
<dt class="col-5 text-secondary">Module state</dt>
<dd class="col-7" id="module-state-dd">
{% if nfc.get('nfc_enabled') %}
<span class="badge text-bg-success"><i class="bi bi-toggle-on me-1"></i>Enabled</span>
{% else %}
<span class="badge text-bg-secondary"><i class="bi bi-toggle-off me-1"></i>Disabled</span>
{% endif %}
</dd>
<dt class="col-5 text-secondary">Trigger relay</dt>
<dd class="col-7 fw-semibold" id="relay-display">Relay {{ nfc.get('relay_num', 1) }}</dd>
<dt class="col-5 text-secondary">Absence timeout</dt>
@@ -101,6 +109,34 @@
<div class="col-lg-7 d-flex flex-column gap-4">
{% if current_user.is_admin() %}
<!-- ── Module Enable / Disable ─────────────────────────────────────── -->
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
<i class="bi bi-toggle-on me-1 text-primary"></i> Mifare / NFC Module
</div>
<div class="card-body d-flex align-items-center justify-content-between gap-3">
<div>
<div class="fw-semibold mb-1">Access control module</div>
<div class="text-secondary small">When disabled the board stops polling the PN532 and will not open any relay on card presentation. The setting persists across power cycles.</div>
</div>
<form method="POST" action="{{ url_for('boards.nfc_enable', board_id=board.id) }}" class="flex-shrink-0">
{% if nfc.get('nfc_enabled') %}
<input type="hidden" name="enabled" value="0">
<button type="submit" id="nfc-toggle-btn"
class="btn btn-success d-flex align-items-center gap-2" style="min-width:140px">
<i class="bi bi-toggle-on fs-5"></i><span>Enabled</span>
</button>
{% else %}
<input type="hidden" name="enabled" value="1">
<button type="submit" id="nfc-toggle-btn"
class="btn btn-secondary d-flex align-items-center gap-2" style="min-width:140px">
<i class="bi bi-toggle-off fs-5"></i><span>Disabled</span>
</button>
{% endif %}
</form>
</div>
</div>
<!-- ── Quick Enroll ──────────────────────────────────────────────────── -->
<div class="card border-0 rounded-4">
<div class="card-header bg-transparent fw-semibold pt-3">
@@ -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
? '<span class="badge text-bg-success"><i class="bi bi-toggle-on me-1"></i>Enabled</span>'
: '<span class="badge text-bg-secondary"><i class="bi bi-toggle-off me-1"></i>Disabled</span>';
}
})
.catch(() => {})
.finally(() => {

View File

@@ -1,10 +1,12 @@
{% set state = device.current_state %}
{% set controllable = device.is_controllable %}
{% set is_bound = device.board is not none %}
<div class="card border-0 rounded-4 h-100
{% if controllable %}
{% if controllable and is_bound %}
{% if state %}border-start border-3 border-{{ device.state_color }}
{% else %}border-start border-3 border-secondary{% endif %}
{% elif not is_bound %}border-start border-3 border-warning
{% else %}border-start border-3 border-primary{% endif %}">
<div class="card-body p-3">
@@ -14,7 +16,7 @@
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
<i class="bi {{ device.effective_icon }}"
style="font-size:1.8rem;
{% if controllable and state %}color:var(--bs-{{ device.state_color }}){% endif %}"></i>
{% if controllable and is_bound and state %}color:var(--bs-{{ device.state_color }}){% endif %}"></i>
</div>
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold text-truncate">{{ device.name }}</div>
@@ -30,13 +32,23 @@
<span class="badge text-bg-secondary" style="font-size:.65rem">
{{ device.device_class | capitalize }}
</span>
{% if device.board %}
{% if is_bound %}
<span class="badge text-bg-secondary" style="font-size:.65rem">
<i class="bi bi-motherboard me-1"></i>{{ device.board.name }}
/ {{ device.entity_type | capitalize }} {{ device.entity_num }}
{% if device.channel_label %}· {{ device.channel_label }}{% endif %}
</span>
{% else %}
<span class="badge text-bg-warning" style="font-size:.65rem">No board</span>
<span class="badge text-bg-warning text-dark" style="font-size:.65rem">
<i class="bi bi-exclamation-circle me-1"></i>Unbound
</span>
{% endif %}
{% if device.app_id %}
<span class="badge text-bg-dark font-monospace copy-app-id"
data-appid="{{ device.app_id }}"
title="App ID: {{ device.app_id }} — click to copy"
style="font-size:.6rem;cursor:pointer">
<i class="bi bi-hash"></i>{{ device.app_id }}
</span>
{% endif %}
</div>
</div>
@@ -45,10 +57,10 @@
<!-- State + controls row -->
<div class="d-flex align-items-center justify-content-between mt-2">
<span class="badge device-state-badge text-bg-{{ device.state_color }}">
{{ device.state_label }}
{% if not is_bound %}Virtual{% else %}{{ device.state_label }}{% endif %}
</span>
<div class="d-flex gap-1">
{% if controllable and device.board %}
{% if controllable and is_bound %}
<button type="button"
class="btn btn-sm {% if state %}btn-{{ device.state_color }}{% else %}btn-outline-secondary{% endif %}"
onclick="deviceToggle(this, {{ device.id }})"
@@ -58,7 +70,7 @@
{% endif %}
{% if current_user.is_admin() %}
<a href="{{ url_for('devices.edit_device', device_id=device.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
class="btn btn-sm btn-outline-secondary" title="Edit / Bind">
<i class="bi bi-pencil"></i>
</a>
<form method="POST"

View File

@@ -1,41 +1,57 @@
{% extends "base.html" %}
{% block title %}{% if device %}Edit Device {{ device.name }}{% else %}Add Device{% endif %}{% endblock %}
{% block title %}{% if device %}Edit Device {{ device.name }}{% else %}New Device{% endif %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('devices.list_devices') }}">Devices</a></li>
<li class="breadcrumb-item active">
{% if device %}Edit {{ device.name }}{% else %}Add Device{% endif %}
{% if device %}Edit {{ device.name }}{% else %}New Device{% endif %}
</li>
</ol>
</nav>
<h2 class="fw-bold mb-1">
<i class="bi bi-hdd-stack me-2 text-info"></i>
{% if device %}Edit Device{% else %}Add Device{% endif %}
</h2>
<p class="text-secondary mb-4">
Give your device a name and map it to a relay or sensor channel on one of your boards.
Once mapped, toggling the device will control the physical hardware.
</p>
<div class="d-flex align-items-start gap-3 mb-4">
<div>
<h2 class="fw-bold mb-1">
<i class="bi bi-hdd-stack me-2 text-info"></i>
{% if device %}Edit Device{% else %}New Device{% endif %}
</h2>
<p class="text-secondary mb-0">
{% if device %}
Update identity or change the hardware binding below.
{% else %}
Define the device identity first — hardware binding is optional and can be added later.
{% endif %}
</p>
</div>
</div>
<form method="POST" id="deviceForm">
<!-- Hidden binding fields submitted with the form -->
<input type="hidden" id="hBoardId" name="board_id" value="{{ device.board_id if device and device.board_id else '' }}" />
<input type="hidden" id="hEntityType" name="entity_type" value="{{ device.entity_type if device and device.entity_type else '' }}" />
<input type="hidden" id="hEntityNum" name="entity_num" value="{{ device.entity_num if device and device.entity_num else '' }}" />
<input type="hidden" id="hBoardId" name="board_id" value="{{ device.board_id if device and device.board_id else '' }}" />
<input type="hidden" id="hEntityType" name="entity_type" value="{{ device.entity_type if device and device.entity_type else '' }}" />
<input type="hidden" id="hEntityNum" name="entity_num" value="{{ device.entity_num if device and device.entity_num else '' }}" />
<input type="hidden" id="hHardwareDeviceId" name="hardware_device_id" value="{{ device.hardware_device_id if device and device.hardware_device_id else '' }}" />
<div class="row g-4">
<!-- ─── Step 1 header ────────────────────────────────────────────────────── -->
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge rounded-pill bg-primary px-3 py-2" style="font-size:.8rem">
<i class="bi bi-1-circle me-1"></i>Device Identity
</span>
<span class="text-secondary small">Required — the virtual device exists independent of any hardware</span>
</div>
<!-- ══════════════════ LEFT: identity + icon + type ══════════════════════ -->
<div class="col-lg-4">
<div class="row g-4 mb-4">
<!-- ══════════════════ LEFT: identity ══════════════════════ -->
<div class="col-lg-5">
<!-- Identity -->
<div class="card border-0 rounded-4 mb-4">
<div class="card border-0 rounded-4 h-100">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-tag me-1 text-info"></i> Identity</h5>
<h5 class="fw-semibold mb-3"><i class="bi bi-tag me-1 text-info"></i> Details</h5>
<div class="mb-3">
<label class="form-label" for="name">Device name <span class="text-danger">*</span></label>
@@ -45,11 +61,37 @@
maxlength="128" required />
</div>
<!-- App ID -->
<div class="mb-3">
<label class="form-label" for="appIdField">
Application ID
<span class="badge text-bg-info ms-1" style="font-size:.65rem">auto-generated</span>
</label>
<div class="input-group">
<span class="input-group-text" style="font-family:monospace;font-size:.85rem">
<i class="bi bi-hash me-1 text-info"></i>
</span>
<input type="text" class="form-control font-monospace" id="appIdField" name="app_id"
value="{{ device.app_id if device and device.app_id else '' }}"
placeholder="auto" maxlength="64"
style="font-size:.85rem" />
{% if device and device.app_id %}
<button type="button" class="btn btn-outline-secondary copy-app-id"
data-appid="{{ device.app_id }}" title="Copy to clipboard">
<i class="bi bi-clipboard"></i>
</button>
{% endif %}
</div>
<div class="form-text">
Stable identifier for layouts &amp; automations. Leave blank to keep current or auto-generate.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="description">Description <span class="text-secondary small">(optional)</span></label>
<input type="text" class="form-control" id="description" name="description"
value="{{ device.description if device and device.description else '' }}"
placeholder="Short description" maxlength="256" />
placeholder="e.g. Controls the outdoor courtyard lighting" maxlength="256" />
</div>
<div class="mb-0">
@@ -61,12 +103,16 @@
</div>
</div>
</div>
</div>
<!-- ══════════════════ RIGHT: type + icon ══════════════════ -->
<div class="col-lg-7">
<!-- Device type -->
<div class="card border-0 rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-3"><i class="bi bi-grid-3x3-gap me-1 text-success"></i> Device Type</h5>
<p class="text-secondary small mb-3">Sets default icon, state labels and colours. Auto-filled when you pick a channel.</p>
<h5 class="fw-semibold mb-1"><i class="bi bi-grid-3x3-gap me-1 text-success"></i> Device Type</h5>
<p class="text-secondary small mb-3">Defines default icon, state labels and colours.</p>
<div id="relay-types-wrap">
<div class="d-flex flex-wrap gap-2">
@@ -103,7 +149,7 @@
<div class="d-flex align-items-center gap-3 mb-3">
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="width:60px;height:60px;background:var(--bs-secondary-bg)">
style="width:58px;height:58px;background:var(--bs-secondary-bg)">
<i id="icon-preview" class="bi {{ (device.icon or device.effective_icon) if device else 'bi-toggles' }}"
style="font-size:2rem"></i>
</div>
@@ -132,21 +178,30 @@
</div>
<!-- ══════════════════ RIGHT: board + entity card picker ═════════════════ -->
<div class="col-lg-8">
<div class="card border-0 rounded-4">
<div class="card-body p-4">
<h5 class="fw-semibold mb-1">
<i class="bi bi-motherboard me-1 text-primary"></i> Board &amp; Channel Binding
</h5>
<p class="text-secondary small mb-4">
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.
</p>
</div><!-- /row step-1 -->
<!-- ─── Step 2 header ────────────────────────────────────────────────────── -->
<div class="d-flex align-items-center gap-2 mb-3">
<span class="badge rounded-pill bg-secondary px-3 py-2" style="font-size:.8rem">
<i class="bi bi-2-circle me-1"></i>Hardware Binding
</span>
<span class="badge text-bg-warning" style="font-size:.7rem">Optional</span>
<span class="text-secondary small">Link to a physical relay, sensor, Tuya or Sonoff channel</span>
</div>
<div class="card border-0 rounded-4 mb-4">
<div class="card-body p-4">
<p class="text-secondary small mb-4">
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.
</p>
<!-- Board selector -->
<div class="mb-4">
<label class="form-label fw-semibold" for="boardSelect">Board</label>
<div class="mb-4" style="max-width:480px">
<label class="form-label fw-semibold" for="boardSelect">
<i class="bi bi-motherboard me-1 text-primary"></i> Board
</label>
<select class="form-select" id="boardSelect">
<option value="">— None (virtual / unlinked device) —</option>
{% for board in boards %}
@@ -193,9 +248,20 @@
</div>
<!-- Selection summary -->
<div id="bindSummary" class="alert alert-info d-flex align-items-center gap-3 mt-3" style="display:none">
<div id="bindSummary" class="alert alert-info d-flex align-items-center gap-3 mt-3"
style="display:{% if device and device.board and device.entity_num %}flex{% else %}none{% endif %}">
<i class="bi bi-check-circle-fill fs-5"></i>
<div><strong>Bound to:</strong> <span id="bindSummaryText"></span></div>
<div>
<strong>Bound to:</strong>
<span id="bindSummaryText">
{% if device and device.board %}
{{ device.board.name }} · {{ device.name }}
{% if device.hardware_device_id %}
<span class="font-monospace text-secondary" style="font-size:.75rem">[{{ device.hardware_device_id }}]</span>
{% endif %}
{% endif %}
</span>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="unbindBtn">
<i class="bi bi-x me-1"></i>Remove binding
</button>
@@ -203,13 +269,10 @@
</div>
</div>
</div>
</div>
</div><!-- /row -->
<!-- ── Submit ────────────────────────────────────────────────────────────── -->
<div class="d-flex gap-2 mt-4">
<div class="d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>
{% if device %}Save Changes{% else %}Create Device{% endif %}
@@ -226,18 +289,18 @@
<script>
(function () {
const INIT = {
boardId: {{ (device.board_id | tojson) if device and device.board_id else 'null' }},
entityType: {{ (device.entity_type | tojson) if device and device.entity_type else 'null' }},
entityNum: {{ (device.entity_num | tojson) if device and device.entity_num else 'null' }},
devClass: {{ (device.device_class | tojson) if device else '"switch"' }},
boardId: {{ (device.board_id if device and device.board_id else none) | tojson }},
entityType: {{ (device.entity_type if device and device.entity_type else none) | tojson }},
entityNum: {{ (device.entity_num if device and device.entity_num else none) | tojson }},
devClass: {{ (device.device_class if device else 'switch') | tojson }},
};
// DOM refs
const boardSel = document.getElementById("boardSelect");
const hBoardId = document.getElementById("hBoardId");
const hEntityType = document.getElementById("hEntityType");
const hEntityNum = document.getElementById("hEntityNum");
const entityLoading = document.getElementById("entityLoading");
const boardSel = document.getElementById("boardSelect");
const hBoardId = document.getElementById("hBoardId");
const hEntityType = document.getElementById("hEntityType");
const hEntityNum = document.getElementById("hEntityNum");
const hHardwareDeviceId = document.getElementById("hHardwareDeviceId");
const entityLoading = document.getElementById("entityLoading");
const entityPh = document.getElementById("entityPlaceholder");
const relaySection = document.getElementById("relaySection");
const inputSection = document.getElementById("inputSection");
@@ -254,7 +317,7 @@
const relayTypesWrap = document.getElementById("relay-types-wrap");
const inputTypesWrap = document.getElementById("input-types-wrap");
// ── icon preview ─────────────────────────────────────────────────────────
// ── icon preview ─────────────────────────────────────────────────────────
function updateIconPreview() {
const val = iconInput.value.trim();
if (val) { iconPreview.className = "bi " + val; return; }
@@ -270,6 +333,30 @@
iconInput.addEventListener("input", updateIconPreview);
iconClearBtn.addEventListener("click", () => { iconInput.value = ""; updateIconPreview(); });
// ── live app_id slug preview ──────────────────────────────────────────────
// Mirrors the server-side Device._make_slug() logic in JavaScript.
// If the user hasn't manually edited the app_id field, auto-fill it from name.
const appIdField = document.getElementById("appIdField");
const nameField = document.getElementById("name");
let appIdManual = !!(appIdField.value.trim()); // true if pre-filled on edit page
function makeSlug(str) {
return str.toLowerCase().trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.substring(0, 60) || "device";
}
appIdField.addEventListener("input", () => {
appIdManual = appIdField.value.trim() !== "";
});
nameField.addEventListener("input", () => {
if (!appIdManual) {
appIdField.value = makeSlug(nameField.value);
}
});
document.getElementById("iconPalette").addEventListener("click", e => {
const btn = e.target.closest("[data-icon]");
if (!btn) return;
@@ -280,6 +367,17 @@
r.addEventListener("change", updateIconPreview)
);
// ── app_id copy button ────────────────────────────────────────────────────
document.addEventListener("click", e => {
const btn = e.target.closest(".copy-app-id");
if (!btn) return;
navigator.clipboard.writeText(btn.dataset.appid).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check2"></i>';
setTimeout(() => { btn.innerHTML = orig; }, 1200);
});
});
// ── device type tab switch ────────────────────────────────────────────────
function showRelayTypes(preselect) {
relayTypesWrap.style.display = "";
@@ -311,11 +409,16 @@
// ── entity card factory ──────────────────────────────────────────────────
function makeCard(item, kind) {
const isSelected = hEntityType.value === kind && parseInt(hEntityNum.value) === item.num;
const actualKind = item.entity_type || kind;
const isSelected = hEntityType.value === actualKind && parseInt(hEntityNum.value) === item.num;
const stateColor = kind === "relay"
? (item.state ? item.on_color : item.off_color)
: (item.active ? item.active_color : item.idle_color);
const label = kind === "relay" ? "Relay" : "Input";
// For gateway devices the item.name is already the full device name;
// only show the numeric channel label for hardware relay/input boards.
const subLabel = (actualKind === "tuya" || actualKind === "sonoff")
? item.type
: (kind === "relay" ? `Relay ${item.num}` : `Input ${item.num}`);
const col = document.createElement("div");
col.className = "col-6 col-md-4 col-xl-3";
@@ -323,7 +426,7 @@
<div class="card entity-pick-card border-2 rounded-3 h-100
${isSelected ? "border-primary bg-primary bg-opacity-10" : "border-transparent"}"
style="cursor:pointer;transition:all .15s"
data-kind="${kind}" data-num="${item.num}">
data-kind="${actualKind}" data-num="${item.num}">
<div class="card-body p-3 text-center">
<div class="rounded-3 d-flex align-items-center justify-content-center mx-auto mb-2"
style="width:48px;height:48px;background:var(--bs-secondary-bg)">
@@ -331,13 +434,13 @@
style="font-size:1.5rem;${isSelected ? "color:var(--bs-primary)" : ""}"></i>
</div>
<div class="fw-semibold small text-truncate" title="${item.name}">${item.name}</div>
<div class="text-secondary" style="font-size:.7rem">${label} ${item.num}</div>
<div class="text-secondary" style="font-size:.7rem">${subLabel}</div>
<span class="badge text-bg-${stateColor} mt-1" style="font-size:.65rem">${item.state_label}</span>
${isSelected ? '<div class="mt-1"><i class="bi bi-check-circle-fill text-primary"></i></div>' : ''}
</div>
</div>`;
col.querySelector(".entity-pick-card").addEventListener("click", () => selectEntity(item, kind));
col.querySelector(".entity-pick-card").addEventListener("click", () => selectEntity(item, actualKind));
return col;
}
@@ -345,6 +448,7 @@
function selectEntity(item, kind) {
hEntityType.value = kind;
hEntityNum.value = item.num;
hHardwareDeviceId.value = item.device_id || "";
document.querySelectorAll(".entity-pick-card").forEach(c => {
const mine = c.dataset.kind === kind && parseInt(c.dataset.num) === item.num;
@@ -367,21 +471,30 @@
}
});
kind === "relay" ? showRelayTypes(item.type) : showInputTypes(item.type);
kind === "input" ? showInputTypes(item.type) : showRelayTypes(item.type);
// Auto-fill name if blank
// Auto-fill name if blank; also update app_id preview
const nameIn = document.getElementById("name");
if (!nameIn.value.trim()) nameIn.value = item.name;
if (!nameIn.value.trim()) {
nameIn.value = item.name;
if (!appIdManual) {
appIdField.value = makeSlug(item.name);
}
}
// Summary banner
const boardName = boardSel.options[boardSel.selectedIndex]?.dataset.name || "Board";
bindSummaryTxt.textContent = `${boardName} · ${kind === "relay" ? "Relay" : "Input"} ${item.num}${item.name}`;
const hwIdLabel = item.device_id ? ` <span class="font-monospace text-secondary" style="font-size:.75rem">[${item.device_id}]</span>` : "";
bindSummaryTxt.innerHTML = `${boardName} · ${item.name}${hwIdLabel}`;
bindSummary.style.display = "flex";
}
unbindBtn.addEventListener("click", () => {
hEntityType.value = "";
hEntityNum.value = "";
hEntityType.value = "";
hEntityNum.value = "";
hBoardId.value = "";
hHardwareDeviceId.value = "";
boardSel.value = "";
document.querySelectorAll(".entity-pick-card").forEach(c => {
c.classList.remove("border-primary", "bg-primary", "bg-opacity-10");
c.classList.add("border-transparent");
@@ -389,6 +502,11 @@
if (ico) ico.style.color = "";
c.querySelector(".entity-check")?.remove();
});
relaySection.style.display = "none";
inputSection.style.display = "none";
relayGrid.innerHTML = "";
inputGrid.innerHTML = "";
entityPh.style.display = "";
bindSummary.style.display = "none";
});
@@ -410,10 +528,23 @@
entityLoading.style.display = "";
fetch(`/devices/api/boards/${boardId}/entities`)
.then(r => r.json())
.then(r => {
if (!r.ok) throw new Error(`Server returned ${r.status}`);
return r.json();
})
.then(data => {
entityLoading.style.display = "none";
// Rename section headings for gateway boards
if (data.board_type === "tuya_cloud" || data.board_type === "sonoff_ewelink") {
const label = data.board_type === "tuya_cloud" ? "Tuya Cloud Devices" : "Sonoff Devices";
const note = data.board_type === "tuya_cloud" ? " controllable via Tuya Cloud" : " controllable via eWeLink";
const relayTitle = document.querySelector("#relaySection .fw-semibold");
if (relayTitle) relayTitle.textContent = label;
const relayNote = document.querySelector("#relaySection .text-secondary.small");
if (relayNote) relayNote.textContent = note;
}
if (data.relays && data.relays.length) {
relayCount.textContent = data.relays.length;
data.relays.forEach(item => relayGrid.appendChild(makeCard(item, "relay")));
@@ -432,21 +563,22 @@
// Restore selection when editing
if (INIT.entityType && INIT.entityNum) {
const all = INIT.entityType === "relay" ? data.relays : data.inputs;
const all = (INIT.entityType === "relay" || INIT.entityType === "tuya" || INIT.entityType === "sonoff") ? data.relays : data.inputs;
const match = all.find(i => i.num === INIT.entityNum);
if (match) selectEntity(match, INIT.entityType);
}
})
.catch(() => {
.catch(err => {
entityLoading.style.display = "none";
entityPh.innerHTML = `<i class="bi bi-exclamation-triangle text-warning me-2"></i>Failed to load board channels.`;
entityPh.innerHTML = `<i class="bi bi-exclamation-triangle text-warning me-2"></i>Failed to load board channels: ${err.message || err}`;
entityPh.style.display = "";
});
}
boardSel.addEventListener("change", () => {
hEntityType.value = "";
hEntityNum.value = "";
hEntityType.value = "";
hEntityNum.value = "";
hHardwareDeviceId.value = "";
loadBoard(boardSel.value || null);
});
@@ -458,11 +590,16 @@
}
updateIconPreview();
if (INIT.boardId) {
loadBoard(INIT.boardId);
// Use INIT.boardId on the edit page; fall back to boardSel.value on the add
// page in case the browser silently restored a previous selection without
// firing a change event (common on page reload / back navigation).
const initialBoardId = INIT.boardId || boardSel.value || null;
if (initialBoardId) {
loadBoard(initialBoardId);
} else {
entityPh.style.display = "";
}
})();
</script>

View File

@@ -2,32 +2,8 @@
{% block title %}Devices Location Management{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="fw-bold mb-0">
<i class="bi bi-hdd-stack me-2 text-info"></i>Devices
</h2>
{% if current_user.is_admin() %}
<a href="{{ url_for('devices.add_device') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add Device
</a>
{% endif %}
</div>
<p class="text-secondary mb-4">
Define named, personalized devices (lights, switches, pumps, sensors…) that map to
specific relay or input channels on your boards. Devices can be placed on Layout pages
as interactive widgets.
</p>
{% if devices %}
{# Group by area #}
{% set areas = devices | map(attribute='area') | unique | list %}
{% set no_area = devices | selectattr('area', 'none') | list
+ devices | selectattr('area', 'equalto', '') | list
+ devices | selectattr('area', 'equalto', None) | list %}
{# Collect non-empty areas #}
{# ── Collect grouped data ─────────────────────────────────────────────────── #}
{% set named_areas = [] %}
{% for d in devices %}
{% if d.area and d.area != '' and d.area not in named_areas %}
@@ -35,7 +11,81 @@
{% endif %}
{% endfor %}
{# Devices with no area #}
{% set bound_count = devices | selectattr('board') | list | length %}
{% set unbound_count = devices | length - bound_count %}
{# ── Page header ──────────────────────────────────────────────────────────── #}
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h2 class="fw-bold mb-0">
<i class="bi bi-hdd-stack me-2 text-info"></i>Devices
</h2>
<p class="text-secondary small mb-0 mt-1">
Virtual devices are independent app entities — bind them to hardware when needed.
</p>
</div>
{% if current_user.is_admin() %}
<a href="{{ url_for('devices.add_device') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add Device
</a>
{% endif %}
</div>
{# ── Stats bar ─────────────────────────────────────────────────────────────── #}
{% if devices %}
<div class="row g-2 mb-4">
<div class="col-4 col-md-3 col-lg-2">
<div class="card border-0 rounded-3 text-center py-2 px-1" style="background:var(--bs-secondary-bg)">
<div class="fw-bold fs-5 mb-0">{{ devices | length }}</div>
<div class="text-secondary" style="font-size:.72rem">Total</div>
</div>
</div>
<div class="col-4 col-md-3 col-lg-2">
<div class="card border-0 rounded-3 text-center py-2 px-1" style="background:var(--bs-secondary-bg)">
<div class="fw-bold fs-5 mb-0 text-success">{{ bound_count }}</div>
<div class="text-secondary" style="font-size:.72rem">Bound</div>
</div>
</div>
<div class="col-4 col-md-3 col-lg-2">
<div class="card border-0 rounded-3 text-center py-2 px-1" style="background:var(--bs-secondary-bg)">
<div class="fw-bold fs-5 mb-0 text-warning">{{ unbound_count }}</div>
<div class="text-secondary" style="font-size:.72rem">Unbound</div>
</div>
</div>
</div>
{# ── Search ────────────────────────────────────────────────────────────────── #}
<div class="mb-4">
<div class="input-group input-group-sm" style="max-width:340px">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="deviceSearch"
placeholder="Filter by name, area or app id…">
<button class="btn btn-outline-secondary" type="button" id="deviceSearchClear">
<i class="bi bi-x"></i>
</button>
</div>
</div>
{# ── Area groups ──────────────────────────────────────────────────────────── #}
{% for area in named_areas %}
<h5 class="text-secondary fw-semibold mb-3 mt-3 area-heading">
<i class="bi bi-geo-alt me-1"></i>{{ area }}
</h5>
<div class="row g-3 mb-2">
{% for device in devices %}
{% if device.area == area %}
<div class="col-md-6 col-xl-4 device-row" id="device-card-{{ device.id }}"
data-name="{{ device.name | lower }}"
data-area="{{ (device.area or '') | lower }}"
data-appid="{{ (device.app_id or '') | lower }}">
{% include "devices/_card.html" %}
</div>
{% endif %}
{% endfor %}
</div>
{% 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 %}
<h5 class="text-secondary fw-semibold mb-3 mt-4">
<i class="bi bi-geo-alt me-1"></i>{{ area }}
</h5>
<div class="row g-3 mb-2">
{% for device in devices %}
{% if device.area == area %}
<div class="col-md-6 col-xl-4" id="device-card-{{ device.id }}">
{% include "devices/_card.html" %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% if ungrouped %}
{% if named_areas %}
<h5 class="text-secondary fw-semibold mb-3 mt-4">
<h5 class="text-secondary fw-semibold mb-3 mt-3 area-heading">
<i class="bi bi-three-dots me-1"></i>Other
</h5>
{% endif %}
<div class="row g-3 mb-2">
{% for device in ungrouped %}
<div class="col-md-6 col-xl-4" id="device-card-{{ device.id }}">
<div class="col-md-6 col-xl-4 device-row" id="device-card-{{ device.id }}"
data-name="{{ device.name | lower }}"
data-area=""
data-appid="{{ (device.app_id or '') | lower }}">
{% include "devices/_card.html" %}
</div>
{% endfor %}
@@ -88,6 +126,7 @@
{% block scripts %}
<script>
// ── toggle relay / Tuya / Sonoff ──────────────────────────────────────────────
function deviceToggle(btn, deviceId) {
btn.disabled = true;
fetch(`/devices/${deviceId}/toggle`, {
@@ -96,23 +135,96 @@ function deviceToggle(btn, deviceId) {
})
.then(r => r.json())
.then(data => {
if (!data.ok) { btn.disabled = false; return; }
if (!data.ok) {
btn.disabled = false;
showToast(data.error || "Toggle failed.", "danger");
return;
}
const card = document.getElementById("device-card-" + deviceId);
const badge = card.querySelector(".device-state-badge");
if (badge) {
badge.className = "badge device-state-badge text-bg-" + data.state_color;
badge.textContent = data.state_label;
}
// Update toggle icon
btn.className = btn.className.replace(/btn-(outline-)?[a-z]+/, "btn-" + data.state_color);
if (data.state) {
btn.innerHTML = '<i class="bi bi-power me-1"></i>ON';
} else {
btn.innerHTML = '<i class="bi bi-power me-1"></i>OFF';
btn.innerHTML = data.state
? '<i class="bi bi-power me-1"></i>ON'
: '<i class="bi bi-power me-1"></i>OFF';
// Update left border colour
const cardEl = card.querySelector(".card");
if (cardEl) {
cardEl.classList.remove("border-success", "border-secondary", "border-warning",
"border-danger", "border-info", "border-primary");
cardEl.classList.add("border-" + data.state_color);
}
btn.disabled = false;
})
.catch(() => { btn.disabled = false; });
.catch(() => { btn.disabled = false; showToast("Network error.", "danger"); });
}
function showToast(msg, type) {
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "toast-container position-fixed bottom-0 end-0 p-3";
container.style.zIndex = 1100;
document.body.appendChild(container);
}
const id = "t" + Date.now();
container.insertAdjacentHTML("beforeend",
`<div id="${id}" class="toast align-items-center text-bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
const el = document.getElementById(id);
const t = new bootstrap.Toast(el, {delay: 4000});
t.show();
el.addEventListener("hidden.bs.toast", () => el.remove());
}
// ── app_id copy ───────────────────────────────────────────────────────────────
document.addEventListener("click", e => {
const btn = e.target.closest(".copy-app-id");
if (!btn) return;
const txt = btn.dataset.appid;
navigator.clipboard.writeText(txt).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check2"></i>';
setTimeout(() => { btn.innerHTML = orig; }, 1200);
});
});
// ── search / filter ────────────────────────────────────────────────────────────
const searchInput = document.getElementById("deviceSearch");
const searchClear = document.getElementById("deviceSearchClear");
if (searchInput) {
function applyFilter() {
const q = searchInput.value.toLowerCase().trim();
let anyVisible = {};
document.querySelectorAll(".device-row").forEach(row => {
const match = !q
|| row.dataset.name.includes(q)
|| row.dataset.area.includes(q)
|| row.dataset.appid.includes(q);
row.style.display = match ? "" : "none";
const heading = row.closest(".row")?.previousElementSibling;
if (heading && heading.classList.contains("area-heading")) {
anyVisible[heading.dataset.id || heading.textContent] = (anyVisible[heading.dataset.id || heading.textContent] || false) || match;
}
});
// Hide area headings whose entire group is filtered out
document.querySelectorAll(".area-heading").forEach(h => {
const nextRow = h.nextElementSibling;
if (!nextRow) return;
const visCount = nextRow.querySelectorAll(".device-row:not([style*='none'])").length;
h.style.display = visCount ? "" : "none";
});
}
searchInput.addEventListener("input", applyFilter);
searchClear.addEventListener("click", () => { searchInput.value = ""; applyFilter(); });
}
</script>
{% endblock %}

View File

@@ -65,6 +65,8 @@
<i class="bi bi-qr-code me-1"></i>Generate QR Code
</button>
<div id="qr-error-area"></div>
<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">
@@ -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);
}
})();
</script>