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