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