Files
location_managemet/app/models/board.py
scheianu 36de1623c2 Add HMAC-SHA256 API authentication to board drivers and edit UI
- Both olimex_esp32_c6_evb and olimex_esp32_c6_evb_pn532 drivers now
  sign every API request with X-Request-Time / X-Request-Sig headers
  using HMAC-SHA256(api_secret, METHOD:path:unix_timestamp)
- Board model gains api_secret column (nullable, default None)
- boards.py edit route saves api_secret from form
- edit.html adds API Secret input with cryptographic Generate button
- If api_secret is empty/None, headers are omitted (backward compat)
2026-03-15 12:33:45 +02:00

176 lines
6.6 KiB
Python
Raw Permalink 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.
"""Board model.
Supported board types
---------------------
olimex_esp32_c6 Olimex ESP32-C6-EVB (4 relays, 4 digital inputs)
Communicates via HTTP REST + webhook callbacks
generic_esp32 Generic ESP32 with custom firmware (configurable I/O)
generic_esp8266 Generic ESP8266 with custom firmware (configurable I/O)
"""
import json
from datetime import datetime
from app import db
class Board(db.Model):
__tablename__ = "boards"
id = db.Column(db.Integer, primary_key=True)
# Human-readable local name
name = db.Column(db.String(128), nullable=False)
board_type = db.Column(db.String(32), nullable=False, default="olimex_esp32_c6_evb")
host = db.Column(db.String(128), nullable=False)
port = db.Column(db.Integer, nullable=False, default=80)
# Number of relays / outputs exposed by this board
num_relays = db.Column(db.Integer, nullable=False, default=4)
# Number of digital inputs
num_inputs = db.Column(db.Integer, nullable=False, default=4)
# JSON blob: {"relay_1": true, "relay_2": false, ...}
relay_states_json = db.Column(db.Text, default="{}")
# JSON blob: {"input_1": false, "input_2": true, ...}
input_states_json = db.Column(db.Text, default="{}")
# Relay and input labels (legacy, kept for backward compat)
labels_json = db.Column(db.Text, default="{}")
# Entity config: {"relay_1": {"type": "light", "name": "...", "icon": ""}, ...}
entities_json = db.Column(db.Text, default="{}")
# Whether this board is currently reachable
is_online = db.Column(db.Boolean, default=False)
last_seen = db.Column(db.DateTime, nullable=True)
# Firmware version string reported by the board
firmware_version = db.Column(db.String(64), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Extra driver-specific config (JSON) e.g. eWeLink credentials/token
config_json = db.Column(db.Text, default="{}")
# HMAC-SHA256 shared secret for API request authentication.
# Must match API_SECRET defined in the board firmware's secrets.h.
# Leave None/empty to disable authentication (open access).
api_secret = db.Column(db.String(128), nullable=True, default=None)
# ── relationships ────────────────────────────────────────────────
workflows_trigger = db.relationship(
"Workflow", foreign_keys="Workflow.trigger_board_id",
back_populates="trigger_board", cascade="all, delete-orphan"
)
workflows_target = db.relationship(
"Workflow", foreign_keys="Workflow.action_board_id",
back_populates="action_board"
)
sonoff_devices = db.relationship(
"SonoffDevice", back_populates="board",
cascade="all, delete-orphan", lazy="dynamic"
)
tuya_devices = db.relationship(
"TuyaDevice", back_populates="board",
cascade="all, delete-orphan", lazy="dynamic"
)
# ── helpers ──────────────────────────────────────────────────────
@property
def relay_states(self) -> dict:
try:
return json.loads(self.relay_states_json or "{}")
except (ValueError, TypeError):
return {}
@relay_states.setter
def relay_states(self, value: dict):
self.relay_states_json = json.dumps(value)
@property
def input_states(self) -> dict:
try:
return json.loads(self.input_states_json or "{}")
except (ValueError, TypeError):
return {}
@input_states.setter
def input_states(self, value: dict):
self.input_states_json = json.dumps(value)
@property
def labels(self) -> dict:
try:
return json.loads(self.labels_json or "{}")
except (ValueError, TypeError):
return {}
@labels.setter
def labels(self, value: dict):
self.labels_json = json.dumps(value)
@property
def entities(self) -> dict:
try:
return json.loads(self.entities_json or "{}")
except (ValueError, TypeError):
return {}
@entities.setter
def entities(self, value: dict):
self.entities_json = json.dumps(value)
def get_relay_entity(self, n: int) -> dict:
from app.models.entity_types import RELAY_ENTITY_TYPES
cfg = self.entities.get(f"relay_{n}", {})
etype = cfg.get("type", "switch")
tdef = RELAY_ENTITY_TYPES.get(etype, RELAY_ENTITY_TYPES["switch"])
# name: entities > legacy labels > type default
name = cfg.get("name", "").strip() or self.labels.get(f"relay_{n}", "") or f"{tdef['label']} {n}"
icon = cfg.get("icon", "").strip() or tdef["icon"]
return {
"name": name,
"icon": icon,
"type": etype,
"on_color": tdef["on"][0],
"on_label": tdef["on"][1],
"off_color": tdef["off"][0],
"off_label": tdef["off"][1],
}
def get_input_entity(self, n: int) -> dict:
from app.models.entity_types import INPUT_ENTITY_TYPES
cfg = self.entities.get(f"input_{n}", {})
etype = cfg.get("type", "generic")
tdef = INPUT_ENTITY_TYPES.get(etype, INPUT_ENTITY_TYPES["generic"])
name = cfg.get("name", "").strip() or self.labels.get(f"input_{n}", "") or f"{tdef['label']} {n}"
icon = cfg.get("icon", "").strip() or tdef["icon"]
return {
"name": name,
"icon": icon,
"type": etype,
"active_color": tdef["active"][0],
"active_label": tdef["active"][1],
"idle_color": tdef["idle"][0],
"idle_label": tdef["idle"][1],
}
@property
def config(self) -> dict:
try:
return json.loads(self.config_json or "{}")
except (ValueError, TypeError):
return {}
@config.setter
def config(self, value: dict):
self.config_json = json.dumps(value)
@property
def is_sonoff_gateway(self) -> bool:
return self.board_type == "sonoff_ewelink"
def get_relay_label(self, relay_num: int) -> str:
return self.get_relay_entity(relay_num)["name"]
def get_input_label(self, input_num: int) -> str:
return self.get_input_entity(input_num)["name"]
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
def __repr__(self) -> str:
return f"<Board {self.name} ({self.board_type}) @ {self.host}:{self.port}>"