Files
location_managemet/app/models/device.py

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