Add optional AI generation, MQTT, OAuth refresh and calendar recurrence
This commit is contained in:
@@ -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
|
||||
@@ -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))
|
||||
)
|
||||
@@ -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