Add NFC enable/disable support; update devices, Sonoff, and Tuya
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user