177 lines
6.2 KiB
Python
177 lines
6.2 KiB
Python
"""Opt-in MQTT ingress shares the display queue, scopes and idempotency."""
|
|||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
|
||
|
|
from fastapi import Depends
|
||
|
|
from pydantic import Field
|
||
|
|
|
||
|
|
from pigway_frame_flow.core.auth import require
|
||
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
||
|
|
from pigway_frame_flow.errors import FlowError
|
||
|
|
from pigway_frame_flow.models import DisplayRequest, StrictModel
|
||
|
|
from pigway_frame_flow.settings import secret
|
||
|
|
from pigway_frame_flow.storage.database import encode
|
||
|
|
|
||
|
|
|
||
|
|
class MQTTConfig(StrictModel):
|
||
|
|
enabled: bool = False
|
||
|
|
host: str = Field(default="", pattern=r"^[a-zA-Z0-9.:-]*$", max_length=253)
|
||
|
|
port: int = Field(default=8883, ge=1, le=65535)
|
||
|
|
tls: bool = True
|
||
|
|
allow_insecure_lan: bool = False
|
||
|
|
topic: str = Field(default="frame-flow/display", pattern=r"^[a-zA-Z0-9_/-]{1,120}$")
|
||
|
|
username: str = Field(default="", max_length=120)
|
||
|
|
|
||
|
|
|
||
|
|
class MQTTBridge:
|
||
|
|
def __init__(self, service):
|
||
|
|
self.service = service
|
||
|
|
self.client = None
|
||
|
|
self.connected = False
|
||
|
|
|
||
|
|
def config(self):
|
||
|
|
row = self.service.db.one("SELECT payload FROM app_config WHERE key='mqtt'")
|
||
|
|
return MQTTConfig.model_validate_json(row["payload"]) if row else MQTTConfig()
|
||
|
|
|
||
|
|
def accept(self, payload, retained=False):
|
||
|
|
if retained:
|
||
|
|
raise FlowError(E.INVALID_REQUEST, "Retained display commands are rejected")
|
||
|
|
if len(payload) > 65536:
|
||
|
|
raise FlowError(E.INVALID_REQUEST, "MQTT command exceeds size limit")
|
||
|
|
token = secret("FRAME_FLOW_MQTT_TOKEN")
|
||
|
|
row = (
|
||
|
|
self.service.db.one(
|
||
|
|
"SELECT * FROM clients WHERE token_hash=? AND enabled=1",
|
||
|
|
(hashlib.sha256(token.encode()).hexdigest(),),
|
||
|
|
)
|
||
|
|
if token
|
||
|
|
else None
|
||
|
|
)
|
||
|
|
if not row:
|
||
|
|
raise FlowError(
|
||
|
|
E.UNAUTHORIZED, "Configure an enabled Frame Flow MQTT client token", 401
|
||
|
|
)
|
||
|
|
scopes = json.loads(row["permissions"])
|
||
|
|
if "admin" not in scopes and "display:write" not in scopes:
|
||
|
|
raise FlowError(E.FORBIDDEN, "MQTT client lacks display:write", 403)
|
||
|
|
self.service.settings.additional_secrets.append(token)
|
||
|
|
data = json.loads(payload)
|
||
|
|
request_id = data.pop("request_id", "")
|
||
|
|
if not isinstance(request_id, str) or not request_id or len(request_id) > 120:
|
||
|
|
raise FlowError(
|
||
|
|
E.INVALID_REQUEST, "MQTT commands require a stable request_id"
|
||
|
|
)
|
||
|
|
request = DisplayRequest.model_validate(data)
|
||
|
|
self.service.validate(request)
|
||
|
|
id = self.service.submit(
|
||
|
|
"display",
|
||
|
|
request.model_dump(mode="json"),
|
||
|
|
request.device_id,
|
||
|
|
row["client_id"],
|
||
|
|
self.service.settings.sanitize(request_id),
|
||
|
|
"mqtt-" + hashlib.sha256(request_id.encode()).hexdigest(),
|
||
|
|
)
|
||
|
|
return {"operation_id": id, "status": "accepted"}
|
||
|
|
|
||
|
|
def start(self):
|
||
|
|
config = self.config()
|
||
|
|
if not config.enabled:
|
||
|
|
return
|
||
|
|
if not config.host or (not config.tls and not config.allow_insecure_lan):
|
||
|
|
raise FlowError(
|
||
|
|
E.CONFIG_REQUIRED,
|
||
|
|
"MQTT requires host and TLS or explicit LAN opt-in",
|
||
|
|
409,
|
||
|
|
)
|
||
|
|
import paho.mqtt.client as mqtt
|
||
|
|
|
||
|
|
self.client = mqtt.Client(
|
||
|
|
mqtt.CallbackAPIVersion.VERSION2,
|
||
|
|
client_id="frame-flow-"
|
||
|
|
+ hashlib.sha256(str(self.service.settings.data_dir).encode()).hexdigest()[
|
||
|
|
:12
|
||
|
|
],
|
||
|
|
)
|
||
|
|
password = secret("FRAME_FLOW_MQTT_PASSWORD")
|
||
|
|
self.service.settings.additional_secrets.append(password)
|
||
|
|
if config.username:
|
||
|
|
self.client.username_pw_set(config.username, password)
|
||
|
|
if config.tls:
|
||
|
|
self.client.tls_set()
|
||
|
|
self.client.reconnect_delay_set(min_delay=5, max_delay=120)
|
||
|
|
|
||
|
|
def connected(client, userdata, flags, reason_code, properties):
|
||
|
|
self.connected = not reason_code.is_failure
|
||
|
|
if self.connected:
|
||
|
|
client.subscribe(config.topic, qos=1)
|
||
|
|
|
||
|
|
def disconnected(client, userdata, flags, reason_code, properties):
|
||
|
|
self.connected = False
|
||
|
|
|
||
|
|
def message(client, userdata, message):
|
||
|
|
try:
|
||
|
|
response = self.accept(message.payload, message.retain)
|
||
|
|
except FlowError as exc:
|
||
|
|
response = {"error": {"code": exc.code, "message": exc.message}}
|
||
|
|
except Exception:
|
||
|
|
response = {
|
||
|
|
"error": {
|
||
|
|
"code": E.INVALID_REQUEST,
|
||
|
|
"message": "Invalid MQTT display request",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
client.publish(
|
||
|
|
config.topic + "/result", encode(response), qos=1, retain=False
|
||
|
|
)
|
||
|
|
|
||
|
|
self.client.on_connect = connected
|
||
|
|
self.client.on_disconnect = disconnected
|
||
|
|
self.client.on_message = message
|
||
|
|
self.client.connect_async(config.host, config.port, keepalive=60)
|
||
|
|
self.client.loop_start()
|
||
|
|
|
||
|
|
def stop(self):
|
||
|
|
if self.client:
|
||
|
|
self.client.disconnect()
|
||
|
|
self.client.loop_stop()
|
||
|
|
self.client = None
|
||
|
|
self.connected = False
|
||
|
|
|
||
|
|
|
||
|
|
def install_mqtt(app, service):
|
||
|
|
service.mqtt = MQTTBridge(service)
|
||
|
|
app.state.mqtt = service.mqtt
|
||
|
|
|
||
|
|
@app.get(
|
||
|
|
"/api/v1/integrations/mqtt",
|
||
|
|
dependencies=[Depends(require("admin"))],
|
||
|
|
tags=["MQTT"],
|
||
|
|
)
|
||
|
|
def state():
|
||
|
|
return {
|
||
|
|
"config": service.mqtt.config().model_dump(),
|
||
|
|
"connected": service.mqtt.connected,
|
||
|
|
}
|
||
|
|
|
||
|
|
@app.put(
|
||
|
|
"/api/v1/integrations/mqtt",
|
||
|
|
dependencies=[Depends(require("admin"))],
|
||
|
|
tags=["MQTT"],
|
||
|
|
)
|
||
|
|
def configure(payload: MQTTConfig):
|
||
|
|
if payload.enabled and (
|
||
|
|
not payload.host or not payload.tls and not payload.allow_insecure_lan
|
||
|
|
):
|
||
|
|
raise FlowError(
|
||
|
|
E.INVALID_REQUEST, "MQTT needs a host and TLS or explicit LAN opt-in"
|
||
|
|
)
|
||
|
|
service.mqtt.stop()
|
||
|
|
service.db.execute(
|
||
|
|
"INSERT OR REPLACE INTO app_config VALUES('mqtt',?)",
|
||
|
|
(payload.model_dump_json(),),
|
||
|
|
)
|
||
|
|
if getattr(app.state, "worker_enabled", False):
|
||
|
|
service.mqtt.start()
|
||
|
|
return state()
|