Files
location_managemet/app/models/sonoff_device.py
ske087 30806560a6 Fix SonoffLAN signing key: hardcode pre-computed HMAC key
The previous _compute_sign_key() function indexed into a base64 string
derived from the full SonoffLAN REGIONS dict (~243 entries). Our partial
dict only produced a 7876-char a string but needed index 7872+, so the
function must use the full dict.

Solution: pre-compute the key once from the full dict and hardcode the
resulting 32-byte ASCII key. This is deterministic — the SonoffLAN
algorithm always produces the same output regardless of when it runs.

The sonoff_ewelink driver now loads cleanly alongside all other drivers.
2026-02-26 20:13:07 +02:00

211 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SonoffDevice model represents an individual Sonoff sub-device
belonging to a Sonoff eWeLink Gateway board."""
import json
from datetime import datetime
from app import db
# -----------------------------------------------------------------
# Device-type → human label + channel count heuristics
# Based on UIIDs from SonoffLAN devices.py
# -----------------------------------------------------------------
UIID_INFO: dict[int, dict] = {
1: {"label": "Basic / BASICR2", "channels": 1, "kind": "switch"},
2: {"label": "Dual Switch", "channels": 2, "kind": "switch"},
3: {"label": "Triple Switch", "channels": 3, "kind": "switch"},
4: {"label": "Quad Switch (4CH)", "channels": 4, "kind": "switch"},
5: {"label": "POW (Power Meter)", "channels": 1, "kind": "switch", "has_power": True},
6: {"label": "Basic (3rd-party)", "channels": 1, "kind": "switch"},
7: {"label": "T1 2C", "channels": 2, "kind": "switch"},
8: {"label": "T1 3C", "channels": 3, "kind": "switch"},
9: {"label": "T1 4C", "channels": 4, "kind": "switch"},
14: {"label": "Basic (3rd-party)", "channels": 1, "kind": "switch"},
15: {"label": "Sonoff TH (Thermostat)", "channels": 1, "kind": "sensor",
"has_temp": True, "has_humidity": True},
22: {"label": "Slampher B1", "channels": 1, "kind": "light"},
24: {"label": "GSM Socket", "channels": 1, "kind": "switch"},
28: {"label": "RF Bridge 433", "channels": 0, "kind": "remote"},
29: {"label": "Dual Switch (2CH)", "channels": 2, "kind": "switch"},
30: {"label": "Triple Switch (3CH)", "channels": 3, "kind": "switch"},
31: {"label": "Quad Switch (4CH)", "channels": 4, "kind": "switch"},
32: {"label": "POWR2 (Power Meter)", "channels": 1, "kind": "switch", "has_power": True},
34: {"label": "iFan02/iFan03", "channels": 1, "kind": "fan"},
44: {"label": "D1 Dimmer", "channels": 1, "kind": "light"},
57: {"label": "Micro USB", "channels": 1, "kind": "switch"},
59: {"label": "LED Light Strip L1", "channels": 1, "kind": "light"},
77: {"label": "Micro Switch", "channels": 1, "kind": "switch"},
78: {"label": "Micro Switch 2CH", "channels": 2, "kind": "switch"},
81: {"label": "Switch (compact)", "channels": 1, "kind": "switch"},
82: {"label": "Switch 2CH (compact)", "channels": 2, "kind": "switch"},
83: {"label": "Switch 3CH (compact)", "channels": 3, "kind": "switch"},
84: {"label": "Switch 4CH (compact)", "channels": 4, "kind": "switch"},
102: {"label": "DW2 Door/Window Sensor", "channels": 0, "kind": "sensor",
"has_door": True},
107: {"label": "MINIR3", "channels": 1, "kind": "switch"},
109: {"label": "DUAL R3", "channels": 2, "kind": "switch"},
126: {"label": "DUAL R3 Lite", "channels": 2, "kind": "switch"},
130: {"label": "POWR3 (Power Meter)", "channels": 1, "kind": "switch", "has_power": True},
133: {"label": "ZBMINI (ZigBee)", "channels": 1, "kind": "switch"},
136: {"label": "B05-BL Light", "channels": 1, "kind": "light"},
138: {"label": "MINIR4", "channels": 1, "kind": "switch"},
160: {"label": "SwitchMan M5-1C", "channels": 1, "kind": "switch"},
161: {"label": "SwitchMan M5-2C", "channels": 2, "kind": "switch"},
162: {"label": "SwitchMan M5-3C", "channels": 3, "kind": "switch"},
190: {"label": "NSPanel", "channels": 2, "kind": "switch"},
195: {"label": "NSPanel Pro", "channels": 1, "kind": "switch"},
}
def _uiid_info(uiid: int) -> dict:
return UIID_INFO.get(uiid, {"label": f"Sonoff UIID-{uiid}", "channels": 1, "kind": "switch"})
class SonoffDevice(db.Model):
__tablename__ = "sonoff_devices"
id = db.Column(db.Integer, primary_key=True)
board_id = db.Column(db.Integer, db.ForeignKey("boards.id"), nullable=False)
# eWeLink device ID (10-char string, e.g. "1000abcdef")
device_id = db.Column(db.String(20), nullable=False)
# User-visible name
name = db.Column(db.String(128), default="")
# LAN address (host:port) — populated when device reachable on LAN
ip_address = db.Column(db.String(64), default="")
port = db.Column(db.Integer, default=8081)
# eWeLink UIID (determines device capabilities/type)
uiid = db.Column(db.Integer, default=1)
# Number of controllable channels
num_channels = db.Column(db.Integer, default=1)
# Firmware/model string
model = db.Column(db.String(64), default="")
firmware = db.Column(db.String(32), default="")
# AES encryption key (needed for non-DIY LAN control)
device_key = db.Column(db.String(128), default="")
# eWeLink device apikey (owner's apikey)
api_key = db.Column(db.String(128), default="")
# Current device state as JSON {"switch":"on"} or {"switches":[...]}
params_json = db.Column(db.Text, default="{}")
# Whether device is currently online
is_online = db.Column(db.Boolean, default=False)
last_seen = db.Column(db.DateTime, nullable=True)
board = db.relationship("Board", back_populates="sonoff_devices")
# ── param helpers ─────────────────────────────────────────────────────────
@property
def params(self) -> dict:
try:
return json.loads(self.params_json or "{}")
except (ValueError, TypeError):
return {}
@params.setter
def params(self, value: dict):
self.params_json = json.dumps(value)
# ── channel state helpers ─────────────────────────────────────────────────
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.
"""
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
def all_channel_states(self) -> list[dict]:
"""Return [{channel, label, state}, ...] for every channel."""
result = []
for ch in range(max(self.num_channels, 1)):
result.append({
"channel": ch,
"label": f"Channel {ch + 1}",
"state": self.get_channel_state(ch),
})
return result
# ── sensor helpers ────────────────────────────────────────────────────────
@property
def temperature(self) -> float | None:
p = self.params
for key in ("temperature", "currentTemperature"):
if key in p:
try:
return float(p[key])
except (TypeError, ValueError):
pass
return None
@property
def humidity(self) -> float | None:
p = self.params
for key in ("humidity", "currentHumidity"):
if key in p:
try:
return float(p[key])
except (TypeError, ValueError):
pass
return None
@property
def power(self) -> float | None:
v = self.params.get("power")
try:
return float(v) if v is not None else None
except (TypeError, ValueError):
return None
@property
def voltage(self) -> float | None:
v = self.params.get("voltage")
try:
return float(v) if v is not None else None
except (TypeError, ValueError):
return None
@property
def current(self) -> float | None:
v = self.params.get("current")
try:
return float(v) if v is not None else None
except (TypeError, ValueError):
return None
# ── display helpers ───────────────────────────────────────────────────────
@property
def device_info(self) -> dict:
return _uiid_info(self.uiid)
@property
def device_label(self) -> str:
return self.device_info.get("label", f"UIID-{self.uiid}")
@property
def kind(self) -> str:
return self.device_info.get("kind", "switch")
@property
def has_power_meter(self) -> bool:
return self.device_info.get("has_power", False)
@property
def has_temperature(self) -> bool:
return self.device_info.get("has_temp", False)
@property
def has_door_sensor(self) -> bool:
return self.device_info.get("has_door", False)
def __repr__(self):
return f"<SonoffDevice {self.device_id} {self.name!r}>"