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_entities_column(app)
|
||||||
_add_config_json_column(app)
|
_add_config_json_column(app)
|
||||||
_migrate_board_types(app)
|
_migrate_board_types(app)
|
||||||
|
_add_device_hardware_id_column(app)
|
||||||
|
_add_device_app_id_column(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -144,3 +146,62 @@ def _seed_admin(app: Flask) -> None:
|
|||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
app.logger.info("Default admin user created (username=admin password=admin)")
|
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}
|
"pulse_ms": int}
|
||||||
GET /nfc/config → {"auth_uid": str, "relay_num": int, "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/config?auth_uid=&relay=&pulse_ms= → {"status": "ok", ...}
|
||||||
|
POST /nfc/enable?state=0|1 → {"status": "ok", "nfc_enabled": bool}
|
||||||
|
|
||||||
Webhook (board → server)
|
Webhook (board → server)
|
||||||
------------------------
|
------------------------
|
||||||
@@ -213,3 +214,26 @@ class OlimexESP32C6EVBPn532Driver(BoardDriver):
|
|||||||
else:
|
else:
|
||||||
logger.warning("NFC config push failed for board '%s'", board.name)
|
logger.warning("NFC config push failed for board '%s'", board.name)
|
||||||
return result is not None
|
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}",
|
"register": "POST /register?callback_url={url}",
|
||||||
"nfc_status": "GET /nfc/status",
|
"nfc_status": "GET /nfc/status",
|
||||||
"nfc_config": "GET /nfc/config",
|
"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"
|
switch_val = "on" if state else "off"
|
||||||
|
|
||||||
# Build params dict
|
# Build params dict — detect format from stored params rather than
|
||||||
if dev.num_channels == 1:
|
# relying only on num_channels, because some devices (e.g. UIID 138)
|
||||||
params = {"switch": switch_val}
|
# report num_channels=1 but actually use the "switches" array format.
|
||||||
command = "switch"
|
stored_params = dev.params
|
||||||
else:
|
uses_switches_fmt = (
|
||||||
|
dev.num_channels > 1
|
||||||
|
or "switches" in stored_params
|
||||||
|
)
|
||||||
|
if uses_switches_fmt:
|
||||||
params = {"switches": [{"outlet": channel, "switch": switch_val}]}
|
params = {"switches": [{"outlet": channel, "switch": switch_val}]}
|
||||||
command = "switches"
|
command = "switches"
|
||||||
|
else:
|
||||||
|
params = {"switch": switch_val}
|
||||||
|
command = "switch"
|
||||||
|
|
||||||
# ── Try LAN first ───────────────────────────────────────────────────
|
# ── Try LAN first ───────────────────────────────────────────────────
|
||||||
if dev.ip_address:
|
if dev.ip_address:
|
||||||
@@ -259,9 +266,8 @@ class SonoffEweLinkDriver(BoardDriver):
|
|||||||
"""Update the SonoffDevice params in-memory after a successful command."""
|
"""Update the SonoffDevice params in-memory after a successful command."""
|
||||||
p = dev.params
|
p = dev.params
|
||||||
switch_val = "on" if state else "off"
|
switch_val = "on" if state else "off"
|
||||||
if dev.num_channels == 1:
|
uses_switches_fmt = dev.num_channels > 1 or "switches" in p
|
||||||
p["switch"] = switch_val
|
if uses_switches_fmt:
|
||||||
else:
|
|
||||||
switches = p.get("switches", [])
|
switches = p.get("switches", [])
|
||||||
updated = False
|
updated = False
|
||||||
for s in switches:
|
for s in switches:
|
||||||
@@ -272,6 +278,8 @@ class SonoffEweLinkDriver(BoardDriver):
|
|||||||
if not updated:
|
if not updated:
|
||||||
switches.append({"outlet": channel, "switch": switch_val})
|
switches.append({"outlet": channel, "switch": switch_val})
|
||||||
p["switches"] = switches
|
p["switches"] = switches
|
||||||
|
else:
|
||||||
|
p["switch"] = switch_val
|
||||||
dev.params = p
|
dev.params = p
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -284,11 +292,12 @@ class SonoffEweLinkDriver(BoardDriver):
|
|||||||
"""
|
"""
|
||||||
import requests as req
|
import requests as req
|
||||||
from .ewelink_api import API, APPID
|
from .ewelink_api import API, APPID
|
||||||
import time
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID}
|
headers = {"Authorization": f"Bearer {at}", "X-CK-Appid": APPID}
|
||||||
|
# v2 /device/thing/status requires "type" (1 = device) and "id", not "deviceid"
|
||||||
payload = {
|
payload = {
|
||||||
"deviceid": device_id,
|
"type": 1,
|
||||||
|
"id": device_id,
|
||||||
"params": params,
|
"params": params,
|
||||||
}
|
}
|
||||||
url = f"{API[region]}/v2/device/thing/status"
|
url = f"{API[region]}/v2/device/thing/status"
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
"""User-defined device abstraction.
|
"""User-defined device abstraction.
|
||||||
|
|
||||||
A Device is a human-friendly alias for a board relay (controllable output) or
|
A Device is a virtual application entity — a named, typed unit (light, switch,
|
||||||
a board digital input (sensor/trigger) that the user wants to expose as a named
|
pump, sensor…) that *optionally* maps to a physical board channel.
|
||||||
entity — e.g. "Outdoor Light 1 (Courtyard)" mapped to Board "Garage" / Relay 4.
|
|
||||||
|
|
||||||
Devices provide an intermediate, named layer so they can later be placed on
|
The two-step model:
|
||||||
Layout pages as interactive widgets without the UI needing raw board/relay IDs.
|
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 datetime import datetime
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
@@ -16,6 +21,12 @@ class Device(db.Model):
|
|||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(128), nullable=False)
|
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)
|
description = db.Column(db.String(256), nullable=True)
|
||||||
area = db.Column(db.String(64), nullable=True) # e.g. "Courtyard", "Living Room"
|
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
|
# Bootstrap-Icons class override; None → use type default
|
||||||
icon = db.Column(db.String(64), nullable=True)
|
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(
|
board_id = db.Column(
|
||||||
db.Integer, db.ForeignKey("boards.id", ondelete="SET NULL"), nullable=True
|
db.Integer, db.ForeignKey("boards.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
entity_type = db.Column(db.String(16), nullable=True) # "relay" | "input"
|
entity_type = db.Column(db.String(16), nullable=True) # "relay" | "input" | "tuya" | "sonoff"
|
||||||
entity_num = db.Column(db.Integer, nullable=True) # 1-based relay/input index
|
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)
|
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 ────────────────────────────────────────────────────────
|
# ── relationships ────────────────────────────────────────────────────────
|
||||||
board = db.relationship("Board", back_populates="devices")
|
board = db.relationship("Board", back_populates="devices")
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────────
|
# ── helpers ──────────────────────────────────────────────────────────────
|
||||||
@property
|
@property
|
||||||
def is_controllable(self) -> bool:
|
def is_controllable(self) -> bool:
|
||||||
"""True when the source entity is a relay (can be toggled ON/OFF)."""
|
"""True when the source entity is a relay or Tuya/Sonoff switch (can be toggled ON/OFF)."""
|
||||||
return self.entity_type == "relay"
|
return self.entity_type in ("relay", "tuya", "sonoff")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_icon(self) -> str:
|
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
|
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES
|
||||||
if self.icon:
|
if self.icon:
|
||||||
return self.icon
|
return self.icon
|
||||||
if self.entity_type == "relay":
|
if self.entity_type in ("relay", "tuya", "sonoff"):
|
||||||
return RELAY_ENTITY_TYPES.get(
|
return RELAY_ENTITY_TYPES.get(
|
||||||
self.device_class, RELAY_ENTITY_TYPES["switch"]
|
self.device_class, RELAY_ENTITY_TYPES["switch"]
|
||||||
)["icon"]
|
)["icon"]
|
||||||
@@ -69,6 +110,25 @@ class Device(db.Model):
|
|||||||
if self.entity_type == "input":
|
if self.entity_type == "input":
|
||||||
raw = self.board.input_states.get(f"input_{self.entity_num}", True)
|
raw = self.board.input_states.get(f"input_{self.entity_num}", True)
|
||||||
return not raw # NC contact: raw True = resting → False = idle
|
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
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -78,7 +138,7 @@ class Device(db.Model):
|
|||||||
state = self.current_state
|
state = self.current_state
|
||||||
if state is None:
|
if state is None:
|
||||||
return "Unknown"
|
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"])
|
tdef = RELAY_ENTITY_TYPES.get(self.device_class, RELAY_ENTITY_TYPES["switch"])
|
||||||
return tdef["on"][1] if state else tdef["off"][1]
|
return tdef["on"][1] if state else tdef["off"][1]
|
||||||
if self.entity_type == "input":
|
if self.entity_type == "input":
|
||||||
@@ -93,7 +153,7 @@ class Device(db.Model):
|
|||||||
state = self.current_state
|
state = self.current_state
|
||||||
if state is None:
|
if state is None:
|
||||||
return "secondary"
|
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"])
|
tdef = RELAY_ENTITY_TYPES.get(self.device_class, RELAY_ENTITY_TYPES["switch"])
|
||||||
return tdef["on"][0] if state else tdef["off"][0]
|
return tdef["on"][0] if state else tdef["off"][0]
|
||||||
if self.entity_type == "input":
|
if self.entity_type == "input":
|
||||||
@@ -101,5 +161,30 @@ class Device(db.Model):
|
|||||||
return tdef["active"][0] if state else tdef["idle"][0]
|
return tdef["active"][0] if state else tdef["idle"][0]
|
||||||
return "secondary"
|
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):
|
def __repr__(self):
|
||||||
return f"<Device {self.name!r} id={self.id}>"
|
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:
|
def get_channel_state(self, channel: int = 0) -> bool:
|
||||||
"""Return True if channel is ON.
|
"""Return True if channel is ON.
|
||||||
For single-channel devices uses 'switch' param.
|
Detects format from stored params: if 'switches' key is present (or
|
||||||
For multi-channel uses 'switches' array with outlet index.
|
num_channels > 1) uses the switches-array format; otherwise uses 'switch'.
|
||||||
"""
|
"""
|
||||||
p = self.params
|
p = self.params
|
||||||
if self.num_channels == 1:
|
uses_switches_fmt = self.num_channels > 1 or "switches" in p
|
||||||
return p.get("switch") == "on"
|
if uses_switches_fmt:
|
||||||
switches = p.get("switches", [])
|
switches = p.get("switches", [])
|
||||||
for s in switches:
|
for s in switches:
|
||||||
if s.get("outlet") == channel:
|
if s.get("outlet") == channel:
|
||||||
return s.get("switch") == "on"
|
return s.get("switch") == "on"
|
||||||
return False
|
return False
|
||||||
|
return p.get("switch") == "on"
|
||||||
|
|
||||||
def all_channel_states(self) -> list[dict]:
|
def all_channel_states(self) -> list[dict]:
|
||||||
"""Return [{channel, label, state}, ...] for every channel."""
|
"""Return [{channel, label, state}, ...] for every channel."""
|
||||||
|
|||||||
@@ -380,3 +380,27 @@ def nfc_enroll(board_id: int):
|
|||||||
else:
|
else:
|
||||||
flash("Card read OK but failed to push config to board.", "danger")
|
flash("Card read OK but failed to push config to board.", "danger")
|
||||||
return redirect(url_for("boards.nfc_management", board_id=board_id))
|
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,
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
flash, request, abort, jsonify)
|
flash, request, abort, jsonify)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.board import Board
|
from app.models.board import Board
|
||||||
from app.models.device import Device
|
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,
|
from app.models.entity_types import (RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES,
|
||||||
ICON_PALETTE)
|
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__)
|
devices_bp = Blueprint("devices", __name__)
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ def add_device():
|
|||||||
board_id = request.form.get("board_id") or None
|
board_id = request.form.get("board_id") or None
|
||||||
entity_type = request.form.get("entity_type") or None
|
entity_type = request.form.get("entity_type") or None
|
||||||
entity_num = request.form.get("entity_num") or None
|
entity_num = request.form.get("entity_num") or None
|
||||||
|
hardware_device_id = request.form.get("hardware_device_id") or None
|
||||||
|
|
||||||
device = Device(
|
device = Device(
|
||||||
name = name,
|
name = name,
|
||||||
@@ -71,11 +73,13 @@ def add_device():
|
|||||||
board_id = int(board_id) if board_id else None,
|
board_id = int(board_id) if board_id else None,
|
||||||
entity_type = entity_type,
|
entity_type = entity_type,
|
||||||
entity_num = int(entity_num) if entity_num else None,
|
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.add(device)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Device '{name}' created successfully.", "success")
|
flash(f"Device '{name}' created. You can now optionally bind it to hardware below.", "success")
|
||||||
return redirect(url_for("devices.list_devices"))
|
return redirect(url_for("devices.edit_device", device_id=device.id))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"devices/edit.html",
|
"devices/edit.html",
|
||||||
@@ -111,6 +115,7 @@ def edit_device(device_id: int):
|
|||||||
board_id = request.form.get("board_id") or None
|
board_id = request.form.get("board_id") or None
|
||||||
entity_type = request.form.get("entity_type") or None
|
entity_type = request.form.get("entity_type") or None
|
||||||
entity_num = request.form.get("entity_num") 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.name = name
|
||||||
device.description = request.form.get("description", "").strip() or None
|
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.board_id = int(board_id) if board_id else None
|
||||||
device.entity_type = entity_type
|
device.entity_type = entity_type
|
||||||
device.entity_num = int(entity_num) if entity_num else None
|
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()
|
db.session.commit()
|
||||||
flash(f"Device '{name}' updated.", "success")
|
flash(f"Device '{name}' updated.", "success")
|
||||||
@@ -160,15 +178,75 @@ def toggle_device(device_id: int):
|
|||||||
if not device.board:
|
if not device.board:
|
||||||
return jsonify({"ok": False, "error": "No board linked."}), 400
|
return jsonify({"ok": False, "error": "No board linked."}), 400
|
||||||
|
|
||||||
# Determine new state (toggle)
|
# ── Sonoff eWeLink toggle ──────────────────────────────────────────────────
|
||||||
current = device.board.relay_states.get(f"relay_{device.entity_num}", False)
|
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
|
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(board, device.entity_num, new_state)
|
||||||
ok = set_relay(device.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)
|
updated_device = db.session.get(Device, device_id)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -186,6 +264,76 @@ def toggle_device(device_id: int):
|
|||||||
def board_entities(board_id: int):
|
def board_entities(board_id: int):
|
||||||
"""Return relay and input entity info for the given board as JSON."""
|
"""Return relay and input entity info for the given board as JSON."""
|
||||||
board = db.get_or_404(Board, board_id)
|
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 = []
|
relays = []
|
||||||
for n in range(1, board.num_relays + 1):
|
for n in range(1, board.num_relays + 1):
|
||||||
e = board.get_relay_entity(n)
|
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>/dp/<dp>/toggle – toggle a DP
|
||||||
POST /tuya/<board_id>/device/<device_id>/rename – rename a device
|
POST /tuya/<board_id>/device/<device_id>/rename – rename a device
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, abort, flash, jsonify, redirect,
|
Blueprint, abort, flash, jsonify, redirect,
|
||||||
render_template, request, url_for,
|
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,
|
TUYA_CLIENT_ID, TUYA_SCHEMA, category_kind, KIND_ICON,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
tuya_bp = Blueprint("tuya", __name__, url_prefix="/tuya")
|
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)
|
response = lc.qr_code(TUYA_CLIENT_ID, TUYA_SCHEMA, user_code)
|
||||||
|
|
||||||
if not response.get("success"):
|
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"]
|
qr_token = response["result"]["qrcode"]
|
||||||
# The URI that Smart Life / Tuya Smart app decodes from the QR:
|
# The URI that Smart Life / Tuya Smart app decodes from the QR:
|
||||||
|
|||||||
@@ -87,6 +87,14 @@
|
|||||||
|
|
||||||
<!-- Current relay & timeout -->
|
<!-- Current relay & timeout -->
|
||||||
<dl class="row mb-0 small">
|
<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>
|
<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>
|
<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>
|
<dt class="col-5 text-secondary">Absence timeout</dt>
|
||||||
@@ -101,6 +109,34 @@
|
|||||||
<div class="col-lg-7 d-flex flex-column gap-4">
|
<div class="col-lg-7 d-flex flex-column gap-4">
|
||||||
|
|
||||||
{% if current_user.is_admin() %}
|
{% 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 ──────────────────────────────────────────────────── -->
|
<!-- ── Quick Enroll ──────────────────────────────────────────────────── -->
|
||||||
<div class="card border-0 rounded-4">
|
<div class="card border-0 rounded-4">
|
||||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
<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);
|
if (relayDisp) relayDisp.textContent = 'Relay ' + (d.relay_num || 1);
|
||||||
const pulseDisp = document.getElementById('pulse-display');
|
const pulseDisp = document.getElementById('pulse-display');
|
||||||
if (pulseDisp) pulseDisp.textContent = (d.pulse_ms || 3000) + ' ms';
|
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(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{% set state = device.current_state %}
|
{% set state = device.current_state %}
|
||||||
{% set controllable = device.is_controllable %}
|
{% set controllable = device.is_controllable %}
|
||||||
|
{% set is_bound = device.board is not none %}
|
||||||
|
|
||||||
<div class="card border-0 rounded-4 h-100
|
<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 }}
|
{% if state %}border-start border-3 border-{{ device.state_color }}
|
||||||
{% else %}border-start border-3 border-secondary{% endif %}
|
{% 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 %}">
|
{% else %}border-start border-3 border-primary{% endif %}">
|
||||||
|
|
||||||
<div class="card-body p-3">
|
<div class="card-body p-3">
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||||||
<i class="bi {{ device.effective_icon }}"
|
<i class="bi {{ device.effective_icon }}"
|
||||||
style="font-size:1.8rem;
|
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>
|
||||||
<div class="flex-grow-1 min-w-0">
|
<div class="flex-grow-1 min-w-0">
|
||||||
<div class="fw-semibold text-truncate">{{ device.name }}</div>
|
<div class="fw-semibold text-truncate">{{ device.name }}</div>
|
||||||
@@ -30,13 +32,23 @@
|
|||||||
<span class="badge text-bg-secondary" style="font-size:.65rem">
|
<span class="badge text-bg-secondary" style="font-size:.65rem">
|
||||||
{{ device.device_class | capitalize }}
|
{{ device.device_class | capitalize }}
|
||||||
</span>
|
</span>
|
||||||
{% if device.board %}
|
{% if is_bound %}
|
||||||
<span class="badge text-bg-secondary" style="font-size:.65rem">
|
<span class="badge text-bg-secondary" style="font-size:.65rem">
|
||||||
<i class="bi bi-motherboard me-1"></i>{{ device.board.name }}
|
<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>
|
</span>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,10 +57,10 @@
|
|||||||
<!-- State + controls row -->
|
<!-- State + controls row -->
|
||||||
<div class="d-flex align-items-center justify-content-between mt-2">
|
<div class="d-flex align-items-center justify-content-between mt-2">
|
||||||
<span class="badge device-state-badge text-bg-{{ device.state_color }}">
|
<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>
|
</span>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
{% if controllable and device.board %}
|
{% if controllable and is_bound %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-sm {% if state %}btn-{{ device.state_color }}{% else %}btn-outline-secondary{% endif %}"
|
class="btn btn-sm {% if state %}btn-{{ device.state_color }}{% else %}btn-outline-secondary{% endif %}"
|
||||||
onclick="deviceToggle(this, {{ device.id }})"
|
onclick="deviceToggle(this, {{ device.id }})"
|
||||||
@@ -58,7 +70,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_admin() %}
|
{% if current_user.is_admin() %}
|
||||||
<a href="{{ url_for('devices.edit_device', device_id=device.id) }}"
|
<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>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
{% extends "base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a href="{{ url_for('devices.list_devices') }}">Devices</a></li>
|
<li class="breadcrumb-item"><a href="{{ url_for('devices.list_devices') }}">Devices</a></li>
|
||||||
<li class="breadcrumb-item active">
|
<li class="breadcrumb-item active">
|
||||||
{% if device %}Edit – {{ device.name }}{% else %}Add Device{% endif %}
|
{% if device %}Edit – {{ device.name }}{% else %}New Device{% endif %}
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h2 class="fw-bold mb-1">
|
<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>
|
<i class="bi bi-hdd-stack me-2 text-info"></i>
|
||||||
{% if device %}Edit Device{% else %}Add Device{% endif %}
|
{% if device %}Edit Device{% else %}New Device{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-secondary mb-4">
|
<p class="text-secondary mb-0">
|
||||||
Give your device a name and map it to a relay or sensor channel on one of your boards.
|
{% if device %}
|
||||||
Once mapped, toggling the device will control the physical hardware.
|
Update identity or change the hardware binding below.
|
||||||
</p>
|
{% else %}
|
||||||
|
Define the device identity first — hardware binding is optional and can be added later.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST" id="deviceForm">
|
<form method="POST" id="deviceForm">
|
||||||
|
|
||||||
@@ -26,16 +33,25 @@
|
|||||||
<input type="hidden" id="hBoardId" name="board_id" value="{{ device.board_id if device and device.board_id 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="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="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="row g-4 mb-4">
|
||||||
<div class="col-lg-4">
|
|
||||||
|
<!-- ══════════════════ LEFT: identity ══════════════════════ -->
|
||||||
|
<div class="col-lg-5">
|
||||||
|
|
||||||
<!-- Identity -->
|
<!-- 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">
|
<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">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="name">Device name <span class="text-danger">*</span></label>
|
<label class="form-label" for="name">Device name <span class="text-danger">*</span></label>
|
||||||
@@ -45,11 +61,37 @@
|
|||||||
maxlength="128" required />
|
maxlength="128" required />
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="description">Description <span class="text-secondary small">(optional)</span></label>
|
<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"
|
<input type="text" class="form-control" id="description" name="description"
|
||||||
value="{{ device.description if device and device.description else '' }}"
|
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>
|
||||||
|
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
@@ -61,12 +103,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════ RIGHT: type + icon ══════════════════ -->
|
||||||
|
<div class="col-lg-7">
|
||||||
|
|
||||||
<!-- Device type -->
|
<!-- Device type -->
|
||||||
<div class="card border-0 rounded-4 mb-4">
|
<div class="card border-0 rounded-4 mb-4">
|
||||||
<div class="card-body p-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>
|
<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">Sets default icon, state labels and colours. Auto-filled when you pick a channel.</p>
|
<p class="text-secondary small mb-3">Defines default icon, state labels and colours.</p>
|
||||||
|
|
||||||
<div id="relay-types-wrap">
|
<div id="relay-types-wrap">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<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="d-flex align-items-center gap-3 mb-3">
|
||||||
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
|
<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' }}"
|
<i id="icon-preview" class="bi {{ (device.icon or device.effective_icon) if device else 'bi-toggles' }}"
|
||||||
style="font-size:2rem"></i>
|
style="font-size:2rem"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,21 +178,30 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════════ RIGHT: board + entity card picker ═════════════════ -->
|
</div><!-- /row step-1 -->
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card border-0 rounded-4">
|
<!-- ─── 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">
|
<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">
|
<p class="text-secondary small mb-4">
|
||||||
Select a board, then click the relay or sensor you want to bind to this device.
|
Select a board, then click a channel to bind this device to physical hardware.
|
||||||
Toggling the device later will directly control the 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>
|
</p>
|
||||||
|
|
||||||
<!-- Board selector -->
|
<!-- Board selector -->
|
||||||
<div class="mb-4">
|
<div class="mb-4" style="max-width:480px">
|
||||||
<label class="form-label fw-semibold" for="boardSelect">Board</label>
|
<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">
|
<select class="form-select" id="boardSelect">
|
||||||
<option value="">— None (virtual / unlinked device) —</option>
|
<option value="">— None (virtual / unlinked device) —</option>
|
||||||
{% for board in boards %}
|
{% for board in boards %}
|
||||||
@@ -193,9 +248,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selection summary -->
|
<!-- 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>
|
<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">
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="unbindBtn">
|
||||||
<i class="bi bi-x me-1"></i>Remove binding
|
<i class="bi bi-x me-1"></i>Remove binding
|
||||||
</button>
|
</button>
|
||||||
@@ -204,12 +270,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- /row -->
|
|
||||||
|
|
||||||
<!-- ── Submit ────────────────────────────────────────────────────────────── -->
|
<!-- ── Submit ────────────────────────────────────────────────────────────── -->
|
||||||
<div class="d-flex gap-2 mt-4">
|
<div class="d-flex gap-2 mt-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
{% if device %}Save Changes{% else %}Create Device{% endif %}
|
{% if device %}Save Changes{% else %}Create Device{% endif %}
|
||||||
@@ -226,17 +289,17 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const INIT = {
|
const INIT = {
|
||||||
boardId: {{ (device.board_id | tojson) if device and device.board_id else 'null' }},
|
boardId: {{ (device.board_id if device and device.board_id else none) | tojson }},
|
||||||
entityType: {{ (device.entity_type | tojson) if device and device.entity_type else 'null' }},
|
entityType: {{ (device.entity_type if device and device.entity_type else none) | tojson }},
|
||||||
entityNum: {{ (device.entity_num | tojson) if device and device.entity_num else 'null' }},
|
entityNum: {{ (device.entity_num if device and device.entity_num else none) | tojson }},
|
||||||
devClass: {{ (device.device_class | tojson) if device else '"switch"' }},
|
devClass: {{ (device.device_class if device else 'switch') | tojson }},
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM refs
|
|
||||||
const boardSel = document.getElementById("boardSelect");
|
const boardSel = document.getElementById("boardSelect");
|
||||||
const hBoardId = document.getElementById("hBoardId");
|
const hBoardId = document.getElementById("hBoardId");
|
||||||
const hEntityType = document.getElementById("hEntityType");
|
const hEntityType = document.getElementById("hEntityType");
|
||||||
const hEntityNum = document.getElementById("hEntityNum");
|
const hEntityNum = document.getElementById("hEntityNum");
|
||||||
|
const hHardwareDeviceId = document.getElementById("hHardwareDeviceId");
|
||||||
const entityLoading = document.getElementById("entityLoading");
|
const entityLoading = document.getElementById("entityLoading");
|
||||||
const entityPh = document.getElementById("entityPlaceholder");
|
const entityPh = document.getElementById("entityPlaceholder");
|
||||||
const relaySection = document.getElementById("relaySection");
|
const relaySection = document.getElementById("relaySection");
|
||||||
@@ -254,7 +317,7 @@
|
|||||||
const relayTypesWrap = document.getElementById("relay-types-wrap");
|
const relayTypesWrap = document.getElementById("relay-types-wrap");
|
||||||
const inputTypesWrap = document.getElementById("input-types-wrap");
|
const inputTypesWrap = document.getElementById("input-types-wrap");
|
||||||
|
|
||||||
// ── icon preview ─────────────────────────────────────────────────────────
|
// ── icon preview ──────────────────────────────────────────────────────────
|
||||||
function updateIconPreview() {
|
function updateIconPreview() {
|
||||||
const val = iconInput.value.trim();
|
const val = iconInput.value.trim();
|
||||||
if (val) { iconPreview.className = "bi " + val; return; }
|
if (val) { iconPreview.className = "bi " + val; return; }
|
||||||
@@ -270,6 +333,30 @@
|
|||||||
|
|
||||||
iconInput.addEventListener("input", updateIconPreview);
|
iconInput.addEventListener("input", updateIconPreview);
|
||||||
iconClearBtn.addEventListener("click", () => { iconInput.value = ""; 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 => {
|
document.getElementById("iconPalette").addEventListener("click", e => {
|
||||||
const btn = e.target.closest("[data-icon]");
|
const btn = e.target.closest("[data-icon]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -280,6 +367,17 @@
|
|||||||
r.addEventListener("change", updateIconPreview)
|
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 ────────────────────────────────────────────────
|
// ── device type tab switch ────────────────────────────────────────────────
|
||||||
function showRelayTypes(preselect) {
|
function showRelayTypes(preselect) {
|
||||||
relayTypesWrap.style.display = "";
|
relayTypesWrap.style.display = "";
|
||||||
@@ -311,11 +409,16 @@
|
|||||||
|
|
||||||
// ── entity card factory ──────────────────────────────────────────────────
|
// ── entity card factory ──────────────────────────────────────────────────
|
||||||
function makeCard(item, kind) {
|
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"
|
const stateColor = kind === "relay"
|
||||||
? (item.state ? item.on_color : item.off_color)
|
? (item.state ? item.on_color : item.off_color)
|
||||||
: (item.active ? item.active_color : item.idle_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");
|
const col = document.createElement("div");
|
||||||
col.className = "col-6 col-md-4 col-xl-3";
|
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
|
<div class="card entity-pick-card border-2 rounded-3 h-100
|
||||||
${isSelected ? "border-primary bg-primary bg-opacity-10" : "border-transparent"}"
|
${isSelected ? "border-primary bg-primary bg-opacity-10" : "border-transparent"}"
|
||||||
style="cursor:pointer;transition:all .15s"
|
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="card-body p-3 text-center">
|
||||||
<div class="rounded-3 d-flex align-items-center justify-content-center mx-auto mb-2"
|
<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)">
|
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>
|
style="font-size:1.5rem;${isSelected ? "color:var(--bs-primary)" : ""}"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="fw-semibold small text-truncate" title="${item.name}">${item.name}</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>
|
<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>' : ''}
|
${isSelected ? '<div class="mt-1"><i class="bi bi-check-circle-fill text-primary"></i></div>' : ''}
|
||||||
</div>
|
</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;
|
return col;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +448,7 @@
|
|||||||
function selectEntity(item, kind) {
|
function selectEntity(item, kind) {
|
||||||
hEntityType.value = kind;
|
hEntityType.value = kind;
|
||||||
hEntityNum.value = item.num;
|
hEntityNum.value = item.num;
|
||||||
|
hHardwareDeviceId.value = item.device_id || "";
|
||||||
|
|
||||||
document.querySelectorAll(".entity-pick-card").forEach(c => {
|
document.querySelectorAll(".entity-pick-card").forEach(c => {
|
||||||
const mine = c.dataset.kind === kind && parseInt(c.dataset.num) === item.num;
|
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");
|
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
|
// Summary banner
|
||||||
const boardName = boardSel.options[boardSel.selectedIndex]?.dataset.name || "Board";
|
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";
|
bindSummary.style.display = "flex";
|
||||||
}
|
}
|
||||||
|
|
||||||
unbindBtn.addEventListener("click", () => {
|
unbindBtn.addEventListener("click", () => {
|
||||||
hEntityType.value = "";
|
hEntityType.value = "";
|
||||||
hEntityNum.value = "";
|
hEntityNum.value = "";
|
||||||
|
hBoardId.value = "";
|
||||||
|
hHardwareDeviceId.value = "";
|
||||||
|
boardSel.value = "";
|
||||||
document.querySelectorAll(".entity-pick-card").forEach(c => {
|
document.querySelectorAll(".entity-pick-card").forEach(c => {
|
||||||
c.classList.remove("border-primary", "bg-primary", "bg-opacity-10");
|
c.classList.remove("border-primary", "bg-primary", "bg-opacity-10");
|
||||||
c.classList.add("border-transparent");
|
c.classList.add("border-transparent");
|
||||||
@@ -389,6 +502,11 @@
|
|||||||
if (ico) ico.style.color = "";
|
if (ico) ico.style.color = "";
|
||||||
c.querySelector(".entity-check")?.remove();
|
c.querySelector(".entity-check")?.remove();
|
||||||
});
|
});
|
||||||
|
relaySection.style.display = "none";
|
||||||
|
inputSection.style.display = "none";
|
||||||
|
relayGrid.innerHTML = "";
|
||||||
|
inputGrid.innerHTML = "";
|
||||||
|
entityPh.style.display = "";
|
||||||
bindSummary.style.display = "none";
|
bindSummary.style.display = "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -410,10 +528,23 @@
|
|||||||
entityLoading.style.display = "";
|
entityLoading.style.display = "";
|
||||||
|
|
||||||
fetch(`/devices/api/boards/${boardId}/entities`)
|
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 => {
|
.then(data => {
|
||||||
entityLoading.style.display = "none";
|
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) {
|
if (data.relays && data.relays.length) {
|
||||||
relayCount.textContent = data.relays.length;
|
relayCount.textContent = data.relays.length;
|
||||||
data.relays.forEach(item => relayGrid.appendChild(makeCard(item, "relay")));
|
data.relays.forEach(item => relayGrid.appendChild(makeCard(item, "relay")));
|
||||||
@@ -432,14 +563,14 @@
|
|||||||
|
|
||||||
// Restore selection when editing
|
// Restore selection when editing
|
||||||
if (INIT.entityType && INIT.entityNum) {
|
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);
|
const match = all.find(i => i.num === INIT.entityNum);
|
||||||
if (match) selectEntity(match, INIT.entityType);
|
if (match) selectEntity(match, INIT.entityType);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(err => {
|
||||||
entityLoading.style.display = "none";
|
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 = "";
|
entityPh.style.display = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -447,6 +578,7 @@
|
|||||||
boardSel.addEventListener("change", () => {
|
boardSel.addEventListener("change", () => {
|
||||||
hEntityType.value = "";
|
hEntityType.value = "";
|
||||||
hEntityNum.value = "";
|
hEntityNum.value = "";
|
||||||
|
hHardwareDeviceId.value = "";
|
||||||
loadBoard(boardSel.value || null);
|
loadBoard(boardSel.value || null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,11 +590,16 @@
|
|||||||
}
|
}
|
||||||
updateIconPreview();
|
updateIconPreview();
|
||||||
|
|
||||||
if (INIT.boardId) {
|
// Use INIT.boardId on the edit page; fall back to boardSel.value on the add
|
||||||
loadBoard(INIT.boardId);
|
// 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 {
|
} else {
|
||||||
entityPh.style.display = "";
|
entityPh.style.display = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,8 @@
|
|||||||
{% block title %}Devices – Location Management{% endblock %}
|
{% block title %}Devices – Location Management{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
{# ── Collect grouped data ─────────────────────────────────────────────────── #}
|
||||||
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 #}
|
|
||||||
{% set named_areas = [] %}
|
{% set named_areas = [] %}
|
||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
{% if d.area and d.area != '' and d.area not in named_areas %}
|
{% if d.area and d.area != '' and d.area not in named_areas %}
|
||||||
@@ -35,7 +11,81 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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 = [] %}
|
{% set ungrouped = [] %}
|
||||||
{% for d in devices %}
|
{% for d in devices %}
|
||||||
{% if not d.area or d.area == '' %}
|
{% if not d.area or d.area == '' %}
|
||||||
@@ -43,30 +93,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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 ungrouped %}
|
||||||
{% if named_areas %}
|
{% 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
|
<i class="bi bi-three-dots me-1"></i>Other
|
||||||
</h5>
|
</h5>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row g-3 mb-2">
|
<div class="row g-3 mb-2">
|
||||||
{% for device in ungrouped %}
|
{% 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" %}
|
{% include "devices/_card.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -88,6 +126,7 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
// ── toggle relay / Tuya / Sonoff ──────────────────────────────────────────────
|
||||||
function deviceToggle(btn, deviceId) {
|
function deviceToggle(btn, deviceId) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
fetch(`/devices/${deviceId}/toggle`, {
|
fetch(`/devices/${deviceId}/toggle`, {
|
||||||
@@ -96,23 +135,96 @@ function deviceToggle(btn, deviceId) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.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 card = document.getElementById("device-card-" + deviceId);
|
||||||
const badge = card.querySelector(".device-state-badge");
|
const badge = card.querySelector(".device-state-badge");
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.className = "badge device-state-badge text-bg-" + data.state_color;
|
badge.className = "badge device-state-badge text-bg-" + data.state_color;
|
||||||
badge.textContent = data.state_label;
|
badge.textContent = data.state_label;
|
||||||
}
|
}
|
||||||
// Update toggle icon
|
|
||||||
btn.className = btn.className.replace(/btn-(outline-)?[a-z]+/, "btn-" + data.state_color);
|
btn.className = btn.className.replace(/btn-(outline-)?[a-z]+/, "btn-" + data.state_color);
|
||||||
if (data.state) {
|
btn.innerHTML = data.state
|
||||||
btn.innerHTML = '<i class="bi bi-power me-1"></i>ON';
|
? '<i class="bi bi-power me-1"></i>ON'
|
||||||
} else {
|
: '<i class="bi bi-power me-1"></i>OFF';
|
||||||
btn.innerHTML = '<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;
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
<i class="bi bi-qr-code me-1"></i>Generate QR Code
|
<i class="bi bi-qr-code me-1"></i>Generate QR Code
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div id="qr-error-area"></div>
|
||||||
|
|
||||||
<div id="qr-area" class="text-center d-none">
|
<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>
|
<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">
|
<p class="text-muted small mb-1" id="qr-instruction">
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
btnCancel.addEventListener('click', cancelQr);
|
btnCancel.addEventListener('click', cancelQr);
|
||||||
|
|
||||||
function startQr() {
|
function startQr() {
|
||||||
|
document.getElementById('qr-error-area').innerHTML = '';
|
||||||
const code = inputCode.value.trim();
|
const code = inputCode.value.trim();
|
||||||
if (!code) {
|
if (!code) {
|
||||||
inputCode.focus();
|
inputCode.focus();
|
||||||
@@ -205,10 +208,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
|
const area = document.getElementById('qr-error-area');
|
||||||
|
area.innerHTML = '';
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'alert alert-danger py-2 mt-2';
|
el.className = 'alert alert-danger py-2 mt-2';
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
qrCanvas.after(el);
|
area.appendChild(el);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Config:
|
|||||||
)
|
)
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
# How often (seconds) the fast poller updates ONLINE boards
|
# How often (seconds) the fast poller updates ONLINE boards
|
||||||
BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 10))
|
BOARD_POLL_INTERVAL = int(os.environ.get("BOARD_POLL_INTERVAL", 60))
|
||||||
# How often (seconds) offline boards are rechecked to see if they came back
|
# How often (seconds) offline boards are rechecked to see if they came back
|
||||||
OFFLINE_RECHECK_INTERVAL = int(os.environ.get("OFFLINE_RECHECK_INTERVAL", 60))
|
OFFLINE_RECHECK_INTERVAL = int(os.environ.get("OFFLINE_RECHECK_INTERVAL", 60))
|
||||||
# Base URL this server is reachable at (boards will POST webhooks here)
|
# Base URL this server is reachable at (boards will POST webhooks here)
|
||||||
|
|||||||
Reference in New Issue
Block a user