Files

172 lines
6.4 KiB
Python

"""Explicit widget mutations, queued once with conservative uncertain-write handling."""
from fastapi import Depends, Header, Request
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 Accepted, StrictModel
from pigway_frame_flow.storage.database import encode
class WidgetSelection(StrictModel):
cover_id: int | None = Field(default=None, gt=0)
args: dict[str, str] = Field(default_factory=dict, max_length=30)
def install_official(app, service):
import json
for device_id, connection in service.registry.official.items():
row = service.db.one(
"SELECT payload FROM app_config WHERE key=?", ("mock-widget:" + device_id,)
)
if connection.reader.mock and row:
connection.reader.mock_selection = json.loads(row["payload"])["cover_id"]
def check(device_id, payload):
service.registry.get(device_id)
connection = service.registry.official.get(device_id)
if not connection:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "This driver has no official widgets", 422
)
if not service.settings.mock and not service.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
view = connection.widgets()
if not view.configured:
raise FlowError(
E.OFFICIAL_AUTH_REQUIRED,
"Connect and associate an official account first",
401,
)
if payload.cover_id is not None:
widget = next((x for x in view.catalog if x.id == payload.cover_id), None)
if not widget:
raise FlowError(
E.INVALID_REQUEST, "Widget is not in the authenticated catalog"
)
if set(payload.args) - set(widget.arg_keys):
raise FlowError(E.INVALID_REQUEST, "Unknown widget parameter")
if any(len(k) > 120 or len(v) > 2000 for k, v in payload.args.items()):
raise FlowError(E.INVALID_REQUEST, "Widget parameter is too long")
elif payload.args:
raise FlowError(
E.INVALID_REQUEST, "Cancellation does not accept parameters"
)
return connection
@app.post(
"/api/v1/devices/{device_id}/widgets/selection",
response_model=Accepted,
status_code=202,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def select(
device_id: str,
payload: WidgetSelection,
request: Request,
idempotency_key: str | None = Header(default=None),
):
check(device_id, payload)
id = service.submit(
"official-widget",
payload.model_dump(),
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):
selection = WidgetSelection.model_validate(payload)
connection = check(op["device_id"], selection)
service.update(op["id"], "UPLOADING")
service.db.execute(
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
)
with connection.lock:
if connection.reader.mock:
connection.reader.mock_selection = selection.cover_id
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("mock-widget:" + op["device_id"], encode(selection.model_dump())),
)
else:
connection.reader.rpc.set_widget(
connection.reader._robot_id, selection.cover_id, selection.args
)
connection.reader._cached = None
try:
view = connection.widgets()
except Exception:
raise FlowError(
E.WRITE_RESULT_UNKNOWN,
"Widget write sent but read-only confirmation failed",
409,
) from None
if (
not view.current_verified
or (view.current.id if view.current else None) != selection.cover_id
):
raise FlowError(
E.WRITE_RESULT_UNKNOWN,
"Widget write acknowledged but selection is not confirmed",
409,
)
service.update(op["id"], "COMPLETED", {"cover_id": selection.cover_id})
service.db.history(op, "COMPLETED")
service.db.event(
"display.changed",
{
"device_id": op["device_id"],
"operation_id": op["id"],
"cover_id": selection.cover_id,
},
)
service.operation_handlers["official-widget"] = execute
def reconcile(op, acknowledge):
id = op["operation_id"]
row = service.db.one("SELECT payload FROM operations WHERE id=?", (id,))
expected_cover = json.loads(row["payload"])["cover_id"]
connection = service.registry.official[op["device_id"]]
with connection.lock:
connection.reader._cached = None
view = connection.widgets()
confirmed = (
view.current_verified
and (view.current.id if view.current else None) == expected_cover
)
resolved = op["status"] != "UNKNOWN"
if op["status"] == "UNKNOWN" and (confirmed or acknowledge):
service.update(
id,
"COMPLETED" if confirmed else "FAILED",
{"cover_id": expected_cover} if confirmed else None,
)
service.db.history(
dict(
id=id,
client_id="admin",
request_id=op["request_id"],
device_id=op["device_id"],
action="reconcile",
),
"CONFIRMED" if confirmed else "ACKNOWLEDGED",
)
resolved = True
return dict(
operation_id=id,
resolved=resolved,
current_image_id=None,
expected_image_id=None,
cloud_images=[],
)
service.reconciliation_handlers["official-widget"] = reconcile