Add optional AI generation, MQTT, OAuth refresh and calendar recurrence
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user