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
+92
View File
@@ -141,6 +141,98 @@
"503"
]
},
"/api/v1/settings/ai-images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/settings/ai-images/key": {
"put": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/ai/images": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/integrations/mqtt": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/layout/images": {
"post": [
"201",
+104
View File
@@ -0,0 +1,104 @@
import base64
import json
from io import BytesIO
from PIL import Image
def test_generate_requires_configuration(client):
assert (
client.post("/api/v1/ai/images", json={"prompt": "Landscape"}).status_code
== 409
)
def test_generation_queued_once_and_never_displays(client, service, monkeypatch):
assert (
client.put(
"/api/v1/settings/ai-images",
json={
"endpoint": "https://example.com/v1/images/generations",
"model": "test-model",
},
).status_code
== 200
)
client.put("/api/v1/settings/ai-images/key", json={"key": "private-ai-key"})
image = BytesIO()
Image.new("RGB", (100, 200), "blue").save(image, "PNG")
calls = []
def fetch(url, **kwargs):
calls.append(kwargs)
return json.dumps(
{"data": [{"b64_json": base64.b64encode(image.getvalue()).decode()}]}
).encode()
monkeypatch.setattr(service.assets, "fetch_url", fetch)
headers = {"Idempotency-Key": "art-once"}
first = client.post(
"/api/v1/ai/images", json={"prompt": "Landscape"}, headers=headers
)
assert first.status_code == 202
assert (
client.post(
"/api/v1/ai/images", json={"prompt": "Landscape"}, headers=headers
).json()
== first.json()
)
service.process_one()
operation = service.operation(first.json()["operation_id"])
assert operation["status"] == "COMPLETED"
assert len(calls) == 1
assert json.loads(calls[0]["body"])["n"] == 1
assert service.registry.get("living-room").client.uploads == 0
assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text
def test_generation_failure_never_retries(client, service, monkeypatch):
client.put(
"/api/v1/settings/ai-images",
json={"endpoint": "https://example.com/v1/images/generations", "model": "test"},
)
client.put("/api/v1/settings/ai-images/key", json={"key": "private-ai-key"})
calls = []
def fail(*args, **kwargs):
calls.append(1)
raise TimeoutError("private-ai-key")
monkeypatch.setattr(service.assets, "fetch_url", fail)
result = client.post("/api/v1/ai/images", json={"prompt": "Landscape"})
service.process_one()
service.process_one()
op = service.operation(result.json()["operation_id"])
assert op["status"] == "FAILED"
assert len(calls) == 1
assert "private-ai-key" not in json.dumps(op)
def test_interrupted_generation_is_never_requeued(app, service, settings):
from pigway_frame_flow.api.app import create_app
id = service.submit(
"render-ai",
{"prompt": "One image"},
"living-room",
"admin",
"interrupted",
"one",
)
service.db.execute(
"UPDATE operations SET status='RENDERING',write_started=1 WHERE id=?", (id,)
)
restarted = create_app(settings, start_worker=False)
assert restarted.state.service.operation(id)["status"] == "UNKNOWN"
assert not restarted.state.service.blocked("living-room")
assert restarted.state.service.process_one() is False
restarted.state.service.db.close()
def test_extension_script_served(client):
response = client.get("/ui/extensions.js")
assert response.status_code == 200
assert "layoutStudioPage" in response.text
+31
View File
@@ -0,0 +1,31 @@
from pigway_frame_flow.models import Power
def test_observer_records_only_changes(service, monkeypatch):
driver = service.registry.get("living-room")
baseline = driver.device()
monkeypatch.setattr(driver, "device", lambda: baseline)
service.device_observer.tick()
before = len(service.db.all("SELECT * FROM events"))
service.device_observer.tick()
assert len(service.db.all("SELECT * FROM events")) == before
changed = baseline.model_copy(
update={"status": "offline", "power": Power(battery_percent=10, charging=False)}
)
monkeypatch.setattr(driver, "device", lambda: changed)
service.device_observer.tick()
assert {r["event_type"] for r in service.db.all("SELECT * FROM events")} >= {
"device.offline",
"device.low_battery",
}
def test_cloud_failure_is_not_device_offline(service, monkeypatch):
driver = service.registry.get("living-room")
def fail():
raise RuntimeError("cloud failure")
monkeypatch.setattr(driver, "device", fail)
service.device_observer.tick()
assert not service.db.all("SELECT * FROM events")
+60
View File
@@ -0,0 +1,60 @@
from datetime import UTC, datetime
import pytest
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.ical import calendar_events
def feed(properties):
return (
"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n"
+ properties
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR"
).encode()
def test_recurring_event_exclusions_and_folded_title():
result = calendar_events(
feed(
"UID:one\r\nDTSTART:20261008T080000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE:20261009T080000Z\r\nSUMMARY:Family\r\n time"
),
datetime(2026, 10, 8, tzinfo=UTC),
)
assert len(result["events"]) == 2
assert result["events"][0]["title"] == "Family time"
assert result["events"][1]["start"].startswith("2026-10-10")
def test_cancelled_event_ignored():
assert (
calendar_events(
feed("DTSTART:20261008T080000Z\r\nSTATUS:CANCELLED\r\nSUMMARY:Cancelled"),
datetime(2026, 10, 8, tzinfo=UTC),
)["events"]
== []
)
def test_high_frequency_rejected():
with pytest.raises(FlowError):
calendar_events(
feed("DTSTART:20261008T080000Z\r\nRRULE:FREQ=SECONDLY\r\nSUMMARY:Bad"),
datetime(2026, 10, 8, tzinfo=UTC),
)
def test_timezone_and_past_filter():
events = calendar_events(
feed("DTSTART;TZID=Asia/Shanghai:20261008T090000\r\nSUMMARY:Meeting"),
datetime(2026, 10, 8, tzinfo=UTC),
)["events"]
assert len(events) == 1
assert events[0]["start"].endswith("+08:00")
assert (
calendar_events(
feed("DTSTART:20260101T090000Z\r\nSUMMARY:Past"),
datetime(2026, 10, 8, tzinfo=UTC),
)["events"]
== []
)
+44
View File
@@ -0,0 +1,44 @@
import json
import pytest
from pigway_frame_flow.errors import FlowError
def test_mqtt_uses_scope_and_durable_idempotency(client, service, monkeypatch):
token = client.post(
"/api/v1/clients",
json={"client_id": "mqtt", "name": "MQTT", "permissions": ["display:write"]},
).json()["token"]
monkeypatch.setenv("FRAME_FLOW_MQTT_TOKEN", token)
payload = json.dumps(
{"request_id": "stable-1", "content": {"type": "template", "template": "quote"}}
).encode()
first = service.mqtt.accept(payload)
assert service.mqtt.accept(payload) == first
assert service.registry.get("living-room").client.uploads == 0
assert service.operation(first["operation_id"])["status"] == "QUEUED"
with pytest.raises(FlowError):
service.mqtt.accept(payload, retained=True)
def test_mqtt_needs_authentication(service, monkeypatch):
monkeypatch.delenv("FRAME_FLOW_MQTT_TOKEN", raising=False)
with pytest.raises(FlowError):
service.mqtt.accept(b"{}")
def test_mqtt_disabled_by_default(client, service):
assert client.get("/api/v1/integrations/mqtt").json()["config"]["enabled"] is False
service.mqtt.start()
assert service.mqtt.client is None
def test_mqtt_refuses_unapproved_plaintext(client):
assert (
client.put(
"/api/v1/integrations/mqtt",
json={"enabled": True, "host": "192.0.2.10", "tls": False},
).status_code
== 400
)
+79
View File
@@ -0,0 +1,79 @@
import json
import time
import pytest
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.oauth import OAuthTokens
@pytest.mark.parametrize("kind", ["google-drive", "alipan", "baidu"])
def test_refresh_fixed_endpoint_private_persistence(
service, monkeypatch, tmp_path, kind
):
calls = []
def fetch(url, **kwargs):
calls.append((url, kwargs))
return json.dumps(
{
"access_token": "new-private-token",
"refresh_token": "rotated-private-token",
"expires_in": 3600,
}
).encode()
monkeypatch.setattr(service.assets, "fetch_url", fetch)
credentials = {
"access_token": "old-token",
"refresh_token": "refresh-private",
"client_id": "app",
"client_secret": "client-private",
}
path = tmp_path / "credentials.secret"
result = OAuthTokens(service.settings, service.assets).refresh(
kind, credentials, path
)
assert calls[0][0] == OAuthTokens.ENDPOINTS[kind]
assert result["expires_at"] > time.time() + 3500
assert json.loads(path.read_text())["refresh_token"] == "rotated-private-token"
assert path.stat().st_mode & 0o777 == 0o600
assert service.settings.sanitize("new-private-token") != "new-private-token"
def test_refresh_failure_never_retries_or_exposes_secret(service, monkeypatch):
calls = []
def fetch(*args, **kwargs):
calls.append(args)
raise RuntimeError("private-secret")
monkeypatch.setattr(service.assets, "fetch_url", fetch)
with pytest.raises(FlowError) as exc:
OAuthTokens(service.settings, service.assets).refresh(
"baidu",
{
"refresh_token": "refresh",
"client_id": "app",
"client_secret": "private-secret",
},
)
assert len(calls) == 1
assert "private-secret" not in str(exc.value)
def test_invalid_refresh_does_not_replace_existing_credentials(
service, monkeypatch, tmp_path
):
path = tmp_path / "secret"
path.write_text("original")
monkeypatch.setattr(
service.assets, "fetch_url", lambda *a, **k: b'{"error":"invalid_grant"}'
)
with pytest.raises(FlowError):
OAuthTokens(service.settings, service.assets).refresh(
"google-drive",
{"refresh_token": "refresh", "client_id": "app", "client_secret": "secret"},
path,
)
assert path.read_text() == "original"