import hashlib import json import threading import time from datetime import UTC, datetime, timedelta from uuid import uuid4 from pigway_frame_flow.drivers.base import DriverRegistry from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import ( DisplayRequest, FutureContent, ImageContent, TemplateContent, URLContent, ) from pigway_frame_flow.renderers.images import convert from pigway_frame_flow.storage.database import encode, now_iso class DisplayService: """One persistent queue; injected driver registry. No vendor imports.""" def __init__(self, db, settings, registry: DriverRegistry, assets, renderer): self.db, self.settings, self.registry, self.assets, self.renderer = ( db, settings, registry, assets, renderer, ) self.lock = threading.RLock() self.expiration_checked: dict[str, float] = {} # A killed write cannot be replayed safely; pre-write work can resume. db.execute( "UPDATE operations SET status='UNKNOWN', updated_at=? WHERE write_started=1 AND status IN ('UPLOADING','RENDERING')", (now_iso(),), ) db.execute( "UPDATE operations SET status='QUEUED' WHERE write_started=0 AND status IN ('RENDERING','UPLOADING')" ) def validate(self, request, writing=True): self.registry.get(request.device_id) if isinstance(request.content, FutureContent): raise FlowError( E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422 ) if isinstance(request.content, TemplateContent): self.renderer.info(request.content.template) if writing and not self.settings.mock and not self.settings.write_enabled: raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403) def submit(self, action, payload, device_id, client_id, request_id, key=None): fingerprint = hashlib.sha256( encode({"action": action, "payload": payload, "device": device_id}).encode() ).hexdigest() with self.db.transaction() as tx: tx.execute("DELETE FROM idempotency WHERE expires_at? ORDER BY priority DESC,created_at DESC LIMIT 1", (device_id, now_iso()), ) def process_one(self): with self.lock: for op in self.db.all( "SELECT * FROM operations WHERE status='QUEUED' ORDER BY created_at LIMIT 50" ): if op["action"] not in ("render", "local-upload") and self.blocked( op["device_id"] ): continue payload = json.loads(op["payload"]) if op["action"] == "display": priority = payload["options"]["priority"] top = self.top(op["device_id"]) if top and priority < top["priority"]: continue try: self._execute(op, payload) except FlowError as exc: uncertain = exc.code == E.WRITE_RESULT_UNKNOWN error = { "code": exc.code, "message": exc.message, "request_id": op["request_id"], "details": {}, } self.update( op["id"], "UNKNOWN" if uncertain else "FAILED", error=error ) self.db.history(op, exc.code) self.db.event( "operation.failed", {"operation_id": op["id"], "code": exc.code} ) except Exception: wrote = self.db.one( "SELECT write_started FROM operations WHERE id=?", (op["id"],) )["write_started"] self.update( op["id"], "UNKNOWN" if wrote else "FAILED", error={ "code": E.WRITE_RESULT_UNKNOWN if wrote else E.INTERNAL_ERROR, "message": "Write outcome unknown; reconcile device state" if wrote else "Operation failed", "request_id": op["request_id"], "details": {}, }, ) self.db.history(op, "UNKNOWN" if wrote else "FAILED") return True return False def _execute(self, op, payload): id, device_id, action = op["id"], op["device_id"], op["action"] driver = self.registry.get(device_id) if action in ("render", "local-upload"): self.update(id, "RENDERING") from pydantic import TypeAdapter from pigway_frame_flow.models import Content asset = self.content_asset(TypeAdapter(Content).validate_python(payload)) if action == "local-upload": asset = self.assets.promote(asset["asset_id"]) self.update( id, "COMPLETED", dict(asset_id=asset["asset_id"], url=asset["url"]) ) self.db.history(op, "COMPLETED") return device = driver.device(refresh=True) if device.status == "offline": raise FlowError(E.DEVICE_OFFLINE, "Target frame is offline", 409) # B101 layout is deliberately not transposed based on rotation. if (device.display.width, device.display.height) != (1200, 1600): raise FlowError( E.UNSUPPORTED_CAPABILITY, "Device geometry does not match renderer", 409 ) previous = device.current_content.id state = self.state(device_id) if action == "delete": image_id = payload["image_id"] protected = {state["previous_image_id"], state["base_image_id"]} | { x["image_id"] for x in self.db.all( "SELECT image_id FROM overrides WHERE device_id=?", (device_id,) ) } if image_id in protected: raise FlowError(E.CONFLICT, "Image is needed for restoration", 409) if image_id == previous and not payload["confirm_current"]: raise FlowError(E.CONFLICT, "Current image needs confirmation", 409) self._start_write(id) driver.delete(image_id) for suffix in [".original", "-thumb.jpg"]: ( self.settings.data_dir / "cache" / f"{device_id}-{image_id}{suffix}" ).unlink(missing_ok=True) self.db.execute( "DELETE FROM image_sources WHERE device_id=? AND image_id=?", (device_id, image_id), ) driver.invalidate() self.update(id, "COMPLETED", {"image_id": image_id}) self.db.history(op, "COMPLETED") return if action == "restore": target = ( state["base_image_id"] if self.top(device_id) else state["previous_image_id"] ) if target is None: raise FlowError(E.CONFLICT, "No previous image to restore", 409) self._start_write(id) driver.display(target) self.db.execute("DELETE FROM overrides WHERE device_id=?", (device_id,)) self.db.execute( "UPDATE device_state SET base_image_id=NULL,managed_image_id=? WHERE device_id=?", (target, device_id), ) driver.invalidate() self.update(id, "SYNCING", {"image_id": target}) self.db.history(op, "ACCEPTED") return request = DisplayRequest.model_validate(payload) content = request.content self.update(id, "RENDERING") asset = None if isinstance(content, ImageContent) and ( content.image_id or content.image_name ): image_id = content.image_id if content.image_name: matches = [x for x in driver.images() if x.name == content.image_name] if len(matches) != 1: raise FlowError( E.CONFLICT, "Name must identify exactly one gallery image", 409 ) image_id = matches[0].image_id else: asset = self.content_asset(content) image_id = None self.db.execute( "UPDATE device_state SET previous_image_id=? WHERE device_id=?", (previous, device_id), ) if request.options.restore_previous and not self.top(device_id): self.db.execute( "UPDATE device_state SET base_image_id=? WHERE device_id=?", (previous, device_id), ) if request.options.restore_previous and previous is None: raise FlowError( E.CONFLICT, "Temporary override requires an existing image", 409 ) self._start_write(id) if asset: self.update(id, "UPLOADING") image_id = driver.upload(self.assets.path(asset["asset_id"]), asset["name"]) elif isinstance(content, ImageContent) and content.image_name: driver.display_by_name(content.image_name) else: driver.display(image_id) result = {"image_id": image_id} if asset: self.assets.promote(asset["asset_id"]) result["rendered_asset_id"] = asset["asset_id"] source = self.db.one( "SELECT payload FROM asset_sources WHERE asset_id=?", (asset["asset_id"],), ) descriptor = source["payload"] if source else encode({"type": "image"}) self.db.execute( "INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)", (device_id, image_id, asset["asset_id"], id, descriptor), ) self.update(id, "SYNCING", result) driver.invalidate() if request.options.restore_previous: expires = ( datetime.now(UTC) + timedelta(seconds=request.options.duration) ).isoformat() self.db.execute( "INSERT INTO overrides VALUES(?,?,?,?,?,?)", (id, device_id, image_id, request.options.priority, expires, now_iso()), ) else: self.db.execute("DELETE FROM overrides WHERE device_id=?", (device_id,)) self.db.execute( "UPDATE device_state SET base_image_id=NULL WHERE device_id=?", (device_id,), ) self.db.execute( "UPDATE device_state SET managed_image_id=? WHERE device_id=?", (image_id, device_id), ) self.db.history(op, "ACCEPTED") self.db.event( "display.changed", {"device_id": device_id, "image_id": image_id, "operation_id": id}, ) def _start_write(self, id): if not self.settings.mock and not self.settings.write_enabled: raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403) self.db.execute( "UPDATE operations SET write_started=1,status='UPLOADING',updated_at=? WHERE id=?", (now_iso(), id), ) def sync(self): for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"): try: device = self.registry.get(op["device_id"]).device() target = json.loads(op["result"])["image_id"] if ( device.current_content.id == target and device.current_content.synced ): self.update(op["id"], "COMPLETED") self.db.history(op, "COMPLETED") self.db.event("operation.completed", {"operation_id": op["id"]}) elif ( datetime.now(UTC) - datetime.fromisoformat(op["updated_at"]) ).total_seconds() > self.settings.sync_timeout: self.update( op["id"], "UNKNOWN", error={ "code": E.WRITE_RESULT_UNKNOWN, "message": "Frame refresh was not confirmed", "request_id": op["request_id"], "details": {}, }, ) except FlowError: pass def expire(self): if not self.settings.mock and not self.settings.write_enabled: return for device_id in self.registry.drivers: if self.blocked(device_id): continue expired = self.db.all( "SELECT * FROM overrides WHERE device_id=? AND expires_at<=?", (device_id, now_iso()), ) if not expired: continue if not self.settings.mock: last = self.expiration_checked.get(device_id, 0) if time.monotonic() - last < 60: continue self.expiration_checked[device_id] = time.monotonic() driver = self.registry.get(device_id) state = self.state(device_id) try: device = driver.device(refresh=True) if device.status == "offline": continue top = self.top(device_id) target = top["image_id"] if top else state["base_image_id"] if target is None: continue if device.current_content.id != state["managed_image_id"]: # A user/app changed the artwork; do not overwrite it. self.db.execute( "DELETE FROM overrides WHERE device_id=?", (device_id,) ) self.db.execute( "UPDATE device_state SET base_image_id=NULL WHERE device_id=?", (device_id,), ) continue if target != device.current_content.id: id = self.submit( "auto-restore", {"image_id": target}, device_id, "scheduler", "restore-" + uuid4().hex, ) op = self.db.one("SELECT * FROM operations WHERE id=?", (id,)) self._start_write(id) try: driver.display(target) except FlowError as exc: self.update( id, "UNKNOWN" if exc.code == E.WRITE_RESULT_UNKNOWN else "FAILED", error={ "code": exc.code, "message": exc.message, "request_id": op["request_id"], "details": {}, }, ) continue driver.invalidate() self.update(id, "SYNCING", {"image_id": target}) self.db.history(op, "ACCEPTED") self.db.execute( "DELETE FROM overrides WHERE device_id=? AND expires_at<=?", (device_id, now_iso()), ) self.db.execute( "UPDATE device_state SET managed_image_id=?,base_image_id=? WHERE device_id=?", (target, state["base_image_id"] if top else None, device_id), ) except FlowError: continue def reconcile(self, id, acknowledge=False): with self.lock: op = self.operation(id) driver = self.registry.get(op["device_id"]) device = driver.device(refresh=True) images = driver.images() expected = (op["result"] or {}).get("image_id") resolved = op["status"] != "UNKNOWN" if op["status"] == "UNKNOWN" and ( acknowledge or ( expected and expected == device.current_content.id and device.current_content.synced ) ): # Acknowledge releases the block; it never resends a write. self.update( id, "COMPLETED" if expected == device.current_content.id else "FAILED", {"image_id": device.current_content.id}, ) resolved = True self.db.history( dict( id=id, client_id="admin", request_id=op["request_id"], device_id=op["device_id"], action="reconcile", ), "CONFIRMED" if expected == device.current_content.id else "ACKNOWLEDGED", ) return dict( operation_id=id, current_image_id=device.current_content.id, expected_image_id=expected, cloud_images=images, resolved=resolved, )