191 lines
8.6 KiB
Python
191 lines
8.6 KiB
Python
"""User-defined device abstraction.
|
|
|
|
A Device is a virtual application entity — a named, typed unit (light, switch,
|
|
pump, sensor…) that *optionally* maps to a physical board channel.
|
|
|
|
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
|
|
|
|
|
|
class Device(db.Model):
|
|
__tablename__ = "devices"
|
|
|
|
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"
|
|
|
|
# Device category — drives default icon and state labels
|
|
device_class = db.Column(db.String(32), nullable=False, default="switch")
|
|
|
|
# Bootstrap-Icons class override; None → use type default
|
|
icon = db.Column(db.String(64), nullable=True)
|
|
|
|
# ── 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" | "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 or Tuya/Sonoff switch (can be toggled ON/OFF)."""
|
|
return self.entity_type in ("relay", "tuya", "sonoff")
|
|
|
|
@property
|
|
def effective_icon(self) -> str:
|
|
"""Icon class to use in the UI (custom override or type default)."""
|
|
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES
|
|
if self.icon:
|
|
return self.icon
|
|
if self.entity_type in ("relay", "tuya", "sonoff"):
|
|
return RELAY_ENTITY_TYPES.get(
|
|
self.device_class, RELAY_ENTITY_TYPES["switch"]
|
|
)["icon"]
|
|
if self.entity_type == "input":
|
|
return INPUT_ENTITY_TYPES.get(
|
|
self.device_class, INPUT_ENTITY_TYPES["generic"]
|
|
)["icon"]
|
|
return "bi-cpu"
|
|
|
|
@property
|
|
def current_state(self):
|
|
"""Returns the current boolean state or None if unavailable."""
|
|
if not self.board or not self.entity_type or not self.entity_num:
|
|
return None
|
|
if self.entity_type == "relay":
|
|
return self.board.relay_states.get(f"relay_{self.entity_num}", False)
|
|
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
|
|
def state_label(self) -> str:
|
|
"""Human-readable state string."""
|
|
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES
|
|
state = self.current_state
|
|
if state is None:
|
|
return "Unknown"
|
|
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":
|
|
tdef = INPUT_ENTITY_TYPES.get(self.device_class, INPUT_ENTITY_TYPES["generic"])
|
|
return tdef["active"][1] if state else tdef["idle"][1]
|
|
return "Unknown"
|
|
|
|
@property
|
|
def state_color(self) -> str:
|
|
"""Bootstrap color name for current state badge."""
|
|
from app.models.entity_types import RELAY_ENTITY_TYPES, INPUT_ENTITY_TYPES
|
|
state = self.current_state
|
|
if state is None:
|
|
return "secondary"
|
|
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":
|
|
tdef = INPUT_ENTITY_TYPES.get(self.device_class, INPUT_ENTITY_TYPES["generic"])
|
|
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}>"
|