172 lines
6.4 KiB
Python
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
|