Add optional AI generation, MQTT, OAuth refresh and calendar recurrence

This commit is contained in:
Pigway
2026-10-08 23:44:07 +08:00
parent 69a0841868
commit 8209aadbb8
24 changed files with 1113 additions and 37 deletions
+176
View File
@@ -0,0 +1,176 @@
"""Optional one-image generation, using the durable operation queue."""
import base64
import json
from io import BytesIO
from typing import Literal
from urllib.parse import urlsplit
from fastapi import Depends, Header, Request
from PIL import Image, ImageOps
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.extensions.api import AISecret
from pigway_frame_flow.extensions.secrets import save_secret
from pigway_frame_flow.models import Accepted, StrictModel
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
from pigway_frame_flow.storage.database import encode
class ImageGenerationConfig(StrictModel):
endpoint: str = Field(max_length=2048)
model: str = Field(min_length=1, max_length=120)
size: Literal["1024x1024", "1024x1536", "1536x1024"] = "1024x1536"
class ImageGenerationInput(StrictModel):
prompt: str = Field(min_length=1, max_length=4000)
def install_ai_images(app, service):
def config():
row = service.db.one("SELECT payload FROM app_config WHERE key='ai_images'")
if not row:
raise FlowError(E.CONFIG_REQUIRED, "Configure image generation first", 409)
return ImageGenerationConfig.model_validate_json(row["payload"])
@app.get(
"/api/v1/settings/ai-images",
dependencies=[Depends(require("admin"))],
tags=["AI"],
)
def get_config():
row = service.db.one("SELECT payload FROM app_config WHERE key='ai_images'")
return {
"config": json.loads(row["payload"]) if row else None,
"key_configured": (
service.settings.config_dir / "ai-images.secret"
).exists(),
}
@app.put(
"/api/v1/settings/ai-images",
dependencies=[Depends(require("admin"))],
tags=["AI"],
)
def set_config(payload: ImageGenerationConfig):
url = urlsplit(payload.endpoint)
if (
url.scheme != "https"
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
or url.port not in (None, 443)
):
raise FlowError(
E.INVALID_REQUEST,
"Image generation requires a plain public HTTPS endpoint",
)
if url.hostname not in service.settings.image_url_hosts:
service.settings.image_url_hosts.append(url.hostname)
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
(encode(service.settings.image_url_hosts),),
)
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES('ai_images',?)",
(payload.model_dump_json(),),
)
return payload
@app.put(
"/api/v1/settings/ai-images/key",
status_code=204,
dependencies=[Depends(require("admin"))],
tags=["AI"],
)
def set_key(payload: AISecret):
key = payload.key.get_secret_value()
save_secret(service.settings.config_dir / "ai-images.secret", key)
service.settings.additional_secrets.append(key)
@app.post(
"/api/v1/ai/images",
status_code=202,
response_model=Accepted,
dependencies=[Depends(require("render:write"))],
tags=["AI"],
)
def generate(
payload: ImageGenerationInput,
request: Request,
idempotency_key: str | None = Header(default=None),
):
config()
if not (service.settings.config_dir / "ai-images.secret").exists():
raise FlowError(
E.CONFIG_REQUIRED, "Configure image generation key first", 409
)
id = service.submit(
"render-ai",
payload.model_dump(),
service.settings.device_id,
request.state.client_id,
request.state.request_id,
idempotency_key,
)
return Accepted(operation_id=id, job_id=id, status="accepted")
def execute(op, payload):
service.update(op["id"], "RENDERING")
conf = config()
key = (service.settings.config_dir / "ai-images.secret").read_text().strip()
service.settings.additional_secrets.append(key)
service.db.execute(
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
)
try:
raw = service.assets.fetch_url(
conf.endpoint,
method="POST",
headers={
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
},
body=json.dumps(
{
"model": conf.model,
"prompt": payload["prompt"],
"size": conf.size,
"n": 1,
}
).encode(),
limit=service.settings.max_bytes,
)
result = json.loads(raw)["data"]
if len(result) != 1:
raise ValueError()
if result[0].get("b64_json"):
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
else:
image_data = service.assets.fetch_url(result[0]["url"])
with Image.open(BytesIO(image_data)) as image:
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError()
rgba = ImageOps.exif_transpose(image).convert("RGBA")
rgb = Image.new("RGB", rgba.size, "white")
rgb.paste(rgba, mask=rgba.getchannel("A"))
output = BytesIO()
rgb.save(output, "JPEG", quality=93)
asset = service.assets.put(output.getvalue(), "ai-art.jpg", "generated")
except Exception:
raise FlowError(
E.RENDER_FAILED,
"Image generation failed; request may have been billed. No automatic retry was sent.",
502,
) from None
service.update(op["id"], "COMPLETED", asset)
service.db.history(op, "COMPLETED")
service.db.event("operation.completed", {"operation_id": op["id"]})
service.operation_handlers["render-ai"] = execute
+9
View File
@@ -42,6 +42,15 @@ def install_extensions(app, service):
from pigway_frame_flow.extensions.providers import install_providers
install_providers(app, service)
from pigway_frame_flow.extensions.ai_images import install_ai_images
install_ai_images(app, service)
from pigway_frame_flow.extensions.mqtt import install_mqtt
install_mqtt(app, service)
from pigway_frame_flow.extensions.device_events import DeviceObserver
service.device_observer = DeviceObserver(service)
db, settings = service.db, service.settings
studio = LayoutStudio(service.renderer)
service.local_retention = LocalRetention(service)
@@ -0,0 +1,49 @@
"""Low-frequency read-only observation emits transitions, never frame writes."""
import json
from pigway_frame_flow.storage.database import encode
class DeviceObserver:
def __init__(self, service):
self.service = service
def tick(self):
for id, driver in self.service.registry.drivers.items():
try:
device = driver.device()
except Exception:
# Cloud query failure is not evidence that the physical device is offline.
continue
low = (
device.power.battery_percent is not None
and device.power.battery_percent <= 20
)
state = {"status": device.status, "low_battery": low}
key = "device-observation:" + id
row = self.service.db.one(
"SELECT payload FROM app_config WHERE key=?", (key,)
)
previous = json.loads(row["payload"]) if row else None
if previous == state:
continue
if previous:
if previous["status"] != state["status"] and state["status"] in (
"online",
"offline",
):
self.service.db.event(
"device." + state["status"], {"device_id": id}
)
if low and not previous["low_battery"]:
self.service.db.event(
"device.low_battery",
{
"device_id": id,
"battery_percent": device.power.battery_percent,
},
)
self.service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)", (key, encode(state))
)
+176
View File
@@ -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()