Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, Header, Query, Request, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from pigway_frame_flow.core.auth import require, seed_client
|
||||
from pigway_frame_flow.core.registry import DeviceRegistry
|
||||
from pigway_frame_flow.display.service import DisplayService
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import (
|
||||
Accepted,
|
||||
Asset,
|
||||
Device,
|
||||
DisplayRequest,
|
||||
ErrorResponse,
|
||||
Fit,
|
||||
GalleryImage,
|
||||
Health,
|
||||
History,
|
||||
ImageContent,
|
||||
Job,
|
||||
JobInput,
|
||||
Operation,
|
||||
Playlist,
|
||||
PlaylistInput,
|
||||
Profile,
|
||||
Readiness,
|
||||
ReconcileRequest,
|
||||
Reconciliation,
|
||||
SettingsView,
|
||||
TemplateConfig,
|
||||
TemplateContent,
|
||||
TemplateInfo,
|
||||
URLContent,
|
||||
Version,
|
||||
)
|
||||
from pigway_frame_flow.providers.weather import MockWeatherProvider
|
||||
from pigway_frame_flow.renderers.images import Assets, convert
|
||||
from pigway_frame_flow.renderers.templates import TemplateRenderer
|
||||
from pigway_frame_flow.scheduler.engine import Scheduler
|
||||
from pigway_frame_flow.settings import Settings
|
||||
from pigway_frame_flow.storage.database import Database, encode
|
||||
|
||||
|
||||
def create_app(settings=None, start_worker=True):
|
||||
settings = settings or Settings()
|
||||
for kind in ["images", "generated", "cache"]:
|
||||
(settings.data_dir / kind).mkdir(parents=True, exist_ok=True)
|
||||
settings.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
db = Database(settings.data_dir / "frameflow.db")
|
||||
registry = DeviceRegistry(settings)
|
||||
assets = Assets(db, settings)
|
||||
renderer = TemplateRenderer(db, settings, MockWeatherProvider(), assets)
|
||||
service = DisplayService(db, settings, registry, assets, renderer)
|
||||
scheduler = Scheduler(service)
|
||||
seed_client(db, settings.api_token)
|
||||
for name in ["urllib3", "requests", "httpx", "httpcore"]:
|
||||
logging.getLogger(name).setLevel(logging.CRITICAL)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
if start_worker:
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.stop()
|
||||
db.close()
|
||||
|
||||
error_responses: dict[int | str, dict[str, Any]] = {
|
||||
code: {"model": ErrorResponse}
|
||||
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
|
||||
}
|
||||
app = FastAPI(
|
||||
title="Pigway Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
app.state.service = service
|
||||
app.state.scheduler = scheduler
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=[
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Idempotency-Key",
|
||||
"X-Request-ID",
|
||||
],
|
||||
)
|
||||
|
||||
def error_response(code, message, status, request):
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"request_id": getattr(request.state, "request_id", uuid4().hex),
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_context(request, call_next):
|
||||
supplied = request.headers.get("X-Request-ID", "")
|
||||
request.state.request_id = (
|
||||
supplied
|
||||
if re.fullmatch(r"[A-Za-z0-9_.:-]{1,120}", supplied)
|
||||
else uuid4().hex
|
||||
)
|
||||
if any(
|
||||
s and s in request.state.request_id
|
||||
for s in [settings.api_key, settings.api_token, *registry.keys]
|
||||
):
|
||||
request.state.request_id = uuid4().hex
|
||||
if request.method in ["POST", "PUT", "DELETE"]:
|
||||
try:
|
||||
chunks = []
|
||||
total = 0
|
||||
async for chunk in request.stream():
|
||||
total += len(chunk)
|
||||
if total > 29_000_000:
|
||||
return error_response(
|
||||
E.INVALID_REQUEST,
|
||||
"Request exceeds size limit",
|
||||
413,
|
||||
request,
|
||||
)
|
||||
chunks.append(chunk)
|
||||
request._body = b"".join(chunks)
|
||||
except Exception:
|
||||
return error_response(
|
||||
E.INVALID_REQUEST, "Invalid request body", 400, request
|
||||
)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
response = error_response(
|
||||
E.INTERNAL_ERROR, "Internal service error", 500, request
|
||||
)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none'"
|
||||
)
|
||||
return response
|
||||
|
||||
@app.exception_handler(FlowError)
|
||||
async def flow_error(request, exc):
|
||||
return error_response(exc.code, exc.message, exc.status, request)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(request, exc):
|
||||
return error_response(
|
||||
E.INVALID_REQUEST, "Request validation failed", 422, request
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(request, exc):
|
||||
return error_response(
|
||||
E.NOT_FOUND if exc.status_code == 404 else E.INVALID_REQUEST,
|
||||
"Resource not found" if exc.status_code == 404 else "HTTP request rejected",
|
||||
exc.status_code,
|
||||
request,
|
||||
)
|
||||
|
||||
def accepted(id):
|
||||
return Accepted(operation_id=id, job_id=id)
|
||||
|
||||
def submit(request, action, payload, device, key):
|
||||
if key and not re.fullmatch(r"[A-Za-z0-9_.:-]{1,200}", key):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid Idempotency-Key")
|
||||
return accepted(
|
||||
service.submit(
|
||||
action,
|
||||
payload,
|
||||
device,
|
||||
request.state.client_id,
|
||||
request.state.request_id,
|
||||
key,
|
||||
)
|
||||
)
|
||||
|
||||
def write_guard(device):
|
||||
registry.get(device)
|
||||
if not settings.mock and not settings.write_enabled:
|
||||
raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403)
|
||||
|
||||
@app.get("/health", response_model=Health, tags=["Health"])
|
||||
def health():
|
||||
return Health(status="ok")
|
||||
|
||||
@app.get(
|
||||
"/ready",
|
||||
response_model=Readiness,
|
||||
tags=["Health"],
|
||||
responses={503: {"model": Readiness}},
|
||||
)
|
||||
def ready():
|
||||
checks = {
|
||||
"sqlite": bool(db.one("SELECT 1 AS ok")),
|
||||
"device_config": bool(registry.drivers)
|
||||
and (settings.mock or all(registry.keys)),
|
||||
"scheduler": scheduler.healthy() if start_worker else True,
|
||||
"authentication": bool(
|
||||
db.one("SELECT client_id FROM clients WHERE enabled=1")
|
||||
),
|
||||
}
|
||||
good = all(checks.values())
|
||||
return JSONResponse(
|
||||
status_code=200 if good else 503,
|
||||
content={"status": "ready" if good else "not_ready", "checks": checks},
|
||||
)
|
||||
|
||||
@app.get("/api/v1/version", response_model=Version, tags=["System"])
|
||||
def version():
|
||||
return Version(
|
||||
commit=os.getenv("BUILD_COMMIT", "development"),
|
||||
build_time=os.getenv("BUILD_TIME", "unknown"),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/devices",
|
||||
response_model=list[Device],
|
||||
tags=["Devices"],
|
||||
dependencies=[Depends(require("devices:read"))],
|
||||
)
|
||||
def devices():
|
||||
return [d.device() for d in registry.drivers.values()]
|
||||
|
||||
@app.get(
|
||||
"/api/v1/devices/{device_id}",
|
||||
response_model=Device,
|
||||
tags=["Devices"],
|
||||
dependencies=[Depends(require("devices:read"))],
|
||||
)
|
||||
def device(device_id: str, refresh: bool = False):
|
||||
return registry.get(device_id).device(refresh)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/devices/{device_id}/images",
|
||||
response_model=list[GalleryImage],
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def images(device_id: str):
|
||||
return registry.get(device_id).images()
|
||||
|
||||
@app.get(
|
||||
"/api/v1/devices/{device_id}/images/{image_id}/file",
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
def image_file(device_id: str, image_id: int, thumbnail: bool = False):
|
||||
if image_id < 1:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image ID must be positive")
|
||||
driver = registry.get(device_id)
|
||||
cached = settings.data_dir / "cache" / f"{device_id}-{image_id}.original"
|
||||
if not cached.exists():
|
||||
data = driver.download(image_id)
|
||||
# Validate and strip EXIF without changing original geometry.
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
if source.width * source.height > 25_000_000:
|
||||
raise ValueError()
|
||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||
raise ValueError()
|
||||
source.verify()
|
||||
cached.write_bytes(data)
|
||||
except Exception:
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
||||
if thumbnail:
|
||||
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
||||
if not thumb.exists():
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(cached) as img:
|
||||
img.thumbnail((300, 400))
|
||||
img.save(thumb, "JPEG")
|
||||
return FileResponse(thumb, media_type="image/jpeg")
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(cached) as original:
|
||||
media_type = Image.MIME.get(
|
||||
original.format or "", "application/octet-stream"
|
||||
)
|
||||
return FileResponse(cached, media_type=media_type)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/display",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Display"],
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
@app.post(
|
||||
"/api/display",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
deprecated=True,
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
def display(
|
||||
payload: DisplayRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
service.validate(payload)
|
||||
write_guard(payload.device_id)
|
||||
return submit(
|
||||
request,
|
||||
"display",
|
||||
payload.model_dump(mode="json"),
|
||||
payload.device_id,
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/devices/{device_id}/display",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Display"],
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
def device_display(
|
||||
device_id: str,
|
||||
payload: DisplayRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
payload = payload.model_copy(update={"device_id": device_id})
|
||||
return display(payload, request, idempotency_key)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/render",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Render"],
|
||||
dependencies=[Depends(require("render:write"))],
|
||||
)
|
||||
def render(
|
||||
payload: TemplateContent,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
renderer.info(payload.template)
|
||||
return submit(
|
||||
request,
|
||||
"render",
|
||||
payload.model_dump(mode="json"),
|
||||
settings.device_id,
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/devices/{device_id}/upload",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
def upload(
|
||||
device_id: str,
|
||||
payload: ImageContent | URLContent,
|
||||
request: Request,
|
||||
display_now: bool = False,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
registry.get(device_id)
|
||||
if isinstance(payload, ImageContent) and (
|
||||
payload.image_id or payload.image_name
|
||||
):
|
||||
raise FlowError(E.INVALID_REQUEST, "Upload needs image bytes or an asset")
|
||||
if display_now:
|
||||
return display(
|
||||
DisplayRequest(device_id=device_id, content=payload),
|
||||
request,
|
||||
idempotency_key,
|
||||
)
|
||||
return submit(
|
||||
request,
|
||||
"local-upload",
|
||||
payload.model_dump(mode="json"),
|
||||
device_id,
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/preview",
|
||||
response_model=Asset,
|
||||
tags=["Render"],
|
||||
dependencies=[Depends(require("render:write"))],
|
||||
)
|
||||
async def preview(
|
||||
request: Request,
|
||||
file: UploadFile = File(),
|
||||
fit: Fit = Form(default=Fit.contain),
|
||||
profile: Profile = Form(default=Profile.color),
|
||||
):
|
||||
data = await file.read(settings.max_bytes + 1)
|
||||
if len(data) > settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
name = Path(file.filename or "image").name[:120]
|
||||
asset = assets.put(
|
||||
await run_in_threadpool(convert, data, fit, profile),
|
||||
settings.sanitize(name),
|
||||
)
|
||||
return asset
|
||||
|
||||
@app.get(
|
||||
"/api/v1/assets",
|
||||
response_model=list[Asset],
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def list_assets(limit: int = Query(default=100, ge=1, le=200)):
|
||||
return [
|
||||
assets.info(r["id"])
|
||||
for r in db.all(
|
||||
"SELECT id FROM assets ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
)
|
||||
]
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/assets/{asset_id}",
|
||||
status_code=204,
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_asset(asset_id: str):
|
||||
path = assets.path(asset_id)
|
||||
reference = "%" + asset_id + "%"
|
||||
if (
|
||||
db.one(
|
||||
"SELECT id FROM operations WHERE payload LIKE ? AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
||||
(reference,),
|
||||
)
|
||||
or db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
|
||||
or db.one(
|
||||
"SELECT template_id FROM template_config WHERE payload LIKE ?",
|
||||
(reference,),
|
||||
)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Asset is referenced by a task, template or pending operation",
|
||||
409,
|
||||
)
|
||||
path.unlink(missing_ok=True)
|
||||
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/assets/{asset_id}",
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
def asset_file(asset_id: str):
|
||||
return FileResponse(assets.path(asset_id), media_type="image/jpeg")
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/devices/{device_id}/images/{image_id}",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Images"],
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_image(
|
||||
device_id: str,
|
||||
image_id: int,
|
||||
request: Request,
|
||||
confirm_current: bool = False,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
write_guard(device_id)
|
||||
if image_id < 1:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image ID must be positive")
|
||||
if (
|
||||
registry.get(device_id).get_current_image() == image_id
|
||||
and not confirm_current
|
||||
):
|
||||
raise FlowError(E.CONFLICT, "Confirm deleting the current image", 409)
|
||||
return submit(
|
||||
request,
|
||||
"delete",
|
||||
{"image_id": image_id, "confirm_current": confirm_current},
|
||||
device_id,
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/devices/{device_id}/restore",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Display"],
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
def restore(
|
||||
device_id: str,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
write_guard(device_id)
|
||||
return submit(request, "restore", {}, device_id, idempotency_key)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/operations",
|
||||
response_model=list[Operation],
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("history:read"))],
|
||||
)
|
||||
def operations(limit: int = Query(default=50, ge=1, le=200)):
|
||||
return [
|
||||
service.operation(x["id"])
|
||||
for x in db.all(
|
||||
"SELECT id FROM operations ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||
)
|
||||
]
|
||||
|
||||
@app.get(
|
||||
"/api/v1/operations/{operation_id}",
|
||||
response_model=Operation,
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("devices:read"))],
|
||||
)
|
||||
def operation(operation_id: str, request: Request):
|
||||
op = service.operation(operation_id)
|
||||
if request.state.client_id not in ("admin", op["client_id"]):
|
||||
raise FlowError(E.FORBIDDEN, "Operation belongs to another client", 403)
|
||||
return op
|
||||
|
||||
@app.post(
|
||||
"/api/v1/operations/{operation_id}/reconcile",
|
||||
response_model=Reconciliation,
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def reconcile(operation_id: str, payload: ReconcileRequest):
|
||||
return service.reconcile(operation_id, payload.acknowledge)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/templates",
|
||||
response_model=list[TemplateInfo],
|
||||
tags=["Templates"],
|
||||
dependencies=[Depends(require("templates:read"))],
|
||||
)
|
||||
def templates():
|
||||
return renderer.list()
|
||||
|
||||
@app.get(
|
||||
"/api/v1/templates/{template_id}",
|
||||
response_model=TemplateInfo,
|
||||
tags=["Templates"],
|
||||
dependencies=[Depends(require("templates:read"))],
|
||||
)
|
||||
def template(template_id: str):
|
||||
return renderer.info(template_id)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/templates/{template_id}/config",
|
||||
response_model=TemplateConfig,
|
||||
tags=["Templates"],
|
||||
dependencies=[Depends(require("settings:write"))],
|
||||
)
|
||||
def template_config(template_id: str, payload: TemplateConfig):
|
||||
renderer.info(template_id)
|
||||
db.execute(
|
||||
"INSERT INTO template_config VALUES(?,?) ON CONFLICT(template_id) DO UPDATE SET payload=excluded.payload",
|
||||
(template_id, encode(settings.sanitize(payload.data))),
|
||||
)
|
||||
return TemplateConfig(data=settings.sanitize(payload.data))
|
||||
|
||||
@app.get(
|
||||
"/api/v1/jobs",
|
||||
response_model=list[Job],
|
||||
tags=["Automation"],
|
||||
dependencies=[Depends(require("jobs:read"))],
|
||||
)
|
||||
def jobs():
|
||||
return scheduler.jobs()
|
||||
|
||||
@app.post(
|
||||
"/api/v1/jobs",
|
||||
response_model=Job,
|
||||
status_code=201,
|
||||
tags=["Automation"],
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def create_job(payload: JobInput):
|
||||
return scheduler.save_job(payload)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/jobs/{job_id}",
|
||||
response_model=Job,
|
||||
tags=["Automation"],
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def update_job(job_id: str, payload: JobInput):
|
||||
if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)):
|
||||
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
||||
return scheduler.save_job(payload, job_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/jobs/{job_id}",
|
||||
status_code=204,
|
||||
tags=["Automation"],
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def delete_job(job_id: str):
|
||||
db.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/playlists",
|
||||
response_model=list[Playlist],
|
||||
tags=["Playlists"],
|
||||
dependencies=[Depends(require("jobs:read"))],
|
||||
)
|
||||
def playlists():
|
||||
return scheduler.playlists()
|
||||
|
||||
@app.post(
|
||||
"/api/v1/playlists",
|
||||
response_model=Playlist,
|
||||
status_code=201,
|
||||
tags=["Playlists"],
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def create_playlist(payload: PlaylistInput):
|
||||
return scheduler.save_playlist(payload)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/playlists/{playlist_id}",
|
||||
response_model=Playlist,
|
||||
tags=["Playlists"],
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def update_playlist(playlist_id: str, payload: PlaylistInput):
|
||||
if not db.one("SELECT id FROM playlists WHERE id=?", (playlist_id,)):
|
||||
raise FlowError(E.NOT_FOUND, "Playlist not found", 404)
|
||||
return scheduler.save_playlist(payload, playlist_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/playlists/{playlist_id}",
|
||||
status_code=204,
|
||||
tags=["Playlists"],
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def delete_playlist(playlist_id: str):
|
||||
db.execute("DELETE FROM playlists WHERE id=?", (playlist_id,))
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/history",
|
||||
response_model=list[History],
|
||||
tags=["History"],
|
||||
dependencies=[Depends(require("history:read"))],
|
||||
)
|
||||
def history(limit: int = Query(default=100, ge=1, le=200)):
|
||||
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings",
|
||||
response_model=SettingsView,
|
||||
tags=["Settings"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def settings_view():
|
||||
return SettingsView(
|
||||
version="0.1.0",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
or all(registry.keys)
|
||||
and bool(registry.keys),
|
||||
api_token_configured=bool(settings.api_token),
|
||||
timezone=settings.timezone,
|
||||
device_id=settings.device_id,
|
||||
weather_provider="mock",
|
||||
)
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
def api_docs():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url="/openapi.json",
|
||||
title="Pigway Frame Flow · API Docs",
|
||||
swagger_js_url="/ui/swagger-ui-bundle.js",
|
||||
swagger_css_url="/ui/swagger-ui.css",
|
||||
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
swagger_ui_parameters={
|
||||
"persistAuthorization": False,
|
||||
"displayRequestDuration": True,
|
||||
},
|
||||
)
|
||||
|
||||
root = Path(__file__).resolve().parent.parent / "web"
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index():
|
||||
return FileResponse(root / "index.html", media_type="text/html")
|
||||
|
||||
@app.get("/ui/{filename}", include_in_schema=False)
|
||||
def static(filename: str):
|
||||
if filename not in [
|
||||
"app.js",
|
||||
"style.css",
|
||||
"i18n.js",
|
||||
"swagger-ui-bundle.js",
|
||||
"swagger-ui.css",
|
||||
]:
|
||||
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
|
||||
return FileResponse(root / filename)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,64 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from fastapi import Request, Security
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
ALL_SCOPES = [
|
||||
"devices:read",
|
||||
"images:read",
|
||||
"images:delete",
|
||||
"display:write",
|
||||
"templates:read",
|
||||
"render:write",
|
||||
"jobs:read",
|
||||
"jobs:write",
|
||||
"history:read",
|
||||
"settings:write",
|
||||
"admin",
|
||||
]
|
||||
|
||||
|
||||
def seed_client(db, token):
|
||||
if token:
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
db.execute(
|
||||
"INSERT INTO clients VALUES(?,?,?,?,?,?,?) ON CONFLICT(client_id) DO UPDATE SET token_hash=excluded.token_hash, enabled=1",
|
||||
("admin", digest, "Administrator", encode(ALL_SCOPES), now_iso(), None, 1),
|
||||
)
|
||||
else:
|
||||
db.execute("UPDATE clients SET enabled=0 WHERE client_id='admin'")
|
||||
|
||||
|
||||
def require(scope):
|
||||
def authorize(request: Request, credentials=Security(bearer)):
|
||||
db = request.app.state.service.db
|
||||
if not credentials:
|
||||
raise FlowError(E.UNAUTHORIZED, "Frame Flow API token required", 401)
|
||||
digest = hashlib.sha256(credentials.credentials.encode()).hexdigest()
|
||||
for row in db.all("SELECT * FROM clients WHERE enabled=1"):
|
||||
if hmac.compare_digest(digest, row["token_hash"]):
|
||||
permissions = json.loads(row["permissions"])
|
||||
if scope not in permissions and "admin" not in permissions:
|
||||
raise FlowError(
|
||||
E.FORBIDDEN, "Token scope does not allow this action", 403
|
||||
)
|
||||
request.state.client_id = row["client_id"]
|
||||
# Avoid writing last_used on every image or poll.
|
||||
if not row["last_used_at"] or (
|
||||
now_iso()[:16] != row["last_used_at"][:16]
|
||||
):
|
||||
db.execute(
|
||||
"UPDATE clients SET last_used_at=? WHERE client_id=?",
|
||||
(now_iso(), row["client_id"]),
|
||||
)
|
||||
return row["client_id"]
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid Frame Flow API token", 401)
|
||||
|
||||
return authorize
|
||||
@@ -0,0 +1,56 @@
|
||||
import yaml
|
||||
|
||||
from pigway_frame_flow.drivers.base import FrameDeviceDriver
|
||||
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
||||
from pigway_frame_flow.drivers.kokonna.driver import KokonnaDriver
|
||||
from pigway_frame_flow.drivers.kokonna.mock import MockClient
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
def __init__(self, settings):
|
||||
self.settings = settings
|
||||
config = settings.config_dir / "devices.yml"
|
||||
devices = (
|
||||
yaml.safe_load(config.read_text())["devices"]
|
||||
if config.exists()
|
||||
else {
|
||||
settings.device_id: {
|
||||
"driver": "kokonna",
|
||||
"api_key_env": "KOKONNA_API_KEY",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
self.drivers: dict[str, FrameDeviceDriver] = {}
|
||||
self.keys = []
|
||||
for id, entry in devices.items():
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
if entry.get("driver") != "kokonna":
|
||||
raise ValueError("Unsupported configured device driver")
|
||||
if not __import__("re").fullmatch(r"[a-z0-9-]{1,60}", id):
|
||||
raise ValueError("Invalid configured device ID")
|
||||
client: MockClient | KokonnaClient
|
||||
if settings.mock:
|
||||
root = settings.data_dir / "cache" / id
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
client = MockClient(root)
|
||||
else:
|
||||
from pigway_frame_flow.settings import secret
|
||||
|
||||
key = (
|
||||
settings.api_key
|
||||
if entry.get("api_key_env", "KOKONNA_API_KEY") == "KOKONNA_API_KEY"
|
||||
else secret(str(entry["api_key_env"]))
|
||||
)
|
||||
self.keys.append(key)
|
||||
settings.additional_secrets.append(key)
|
||||
client = KokonnaClient(key, settings.write_enabled)
|
||||
self.drivers[id] = KokonnaDriver(client, id, settings.sanitize)
|
||||
|
||||
def get(self, id: str) -> FrameDeviceDriver:
|
||||
if id not in self.drivers:
|
||||
raise FlowError(E.DEVICE_NOT_FOUND, "Device not found", 404)
|
||||
return self.drivers[id]
|
||||
@@ -0,0 +1,510 @@
|
||||
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<?", (now_iso(),))
|
||||
if key:
|
||||
existing = tx.execute(
|
||||
"SELECT * FROM idempotency WHERE client_id=? AND key=?",
|
||||
(client_id, key),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["fingerprint"] != fingerprint:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Idempotency key has a different request", 409
|
||||
)
|
||||
return existing["operation_id"]
|
||||
if (
|
||||
action not in ("render", "local-upload")
|
||||
and tx.execute(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 LIMIT 1",
|
||||
(device_id,),
|
||||
).fetchone()
|
||||
):
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN,
|
||||
"Write result unknown; reconcile device state",
|
||||
409,
|
||||
)
|
||||
id = uuid4().hex
|
||||
now = now_iso()
|
||||
tx.execute(
|
||||
"INSERT INTO operations(id,status,action,payload,device_id,client_id,request_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
id,
|
||||
"QUEUED",
|
||||
action,
|
||||
encode(self.settings.sanitize(payload)),
|
||||
device_id,
|
||||
client_id,
|
||||
request_id,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
if key:
|
||||
expires = (
|
||||
datetime.now(UTC) + timedelta(hours=self.settings.idempotency_hours)
|
||||
).isoformat()
|
||||
tx.execute(
|
||||
"INSERT INTO idempotency VALUES(?,?,?,?,?)",
|
||||
(client_id, key, fingerprint, id, expires),
|
||||
)
|
||||
return id
|
||||
|
||||
def operation(self, id):
|
||||
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
|
||||
if not op:
|
||||
raise FlowError(E.NOT_FOUND, "Operation not found", 404)
|
||||
return dict(
|
||||
operation_id=op["id"],
|
||||
status=op["status"],
|
||||
device_id=op["device_id"],
|
||||
created_at=op["created_at"],
|
||||
updated_at=op["updated_at"],
|
||||
result=json.loads(op["result"]) if op["result"] else None,
|
||||
error=json.loads(op["error"]) if op["error"] else None,
|
||||
request_id=op["request_id"],
|
||||
client_id=op["client_id"],
|
||||
action=op["action"],
|
||||
)
|
||||
|
||||
def update(self, id, status, result=None, error=None):
|
||||
self.db.execute(
|
||||
"UPDATE operations SET status=?,updated_at=?,result=COALESCE(?,result),error=? WHERE id=?",
|
||||
(
|
||||
status,
|
||||
now_iso(),
|
||||
encode(result) if result else None,
|
||||
encode(error) if error else None,
|
||||
id,
|
||||
),
|
||||
)
|
||||
|
||||
def blocked(self, device_id):
|
||||
return (
|
||||
self.db.one(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 LIMIT 1",
|
||||
(device_id,),
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
def state(self, id):
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO device_state(device_id) VALUES(?)", (id,)
|
||||
)
|
||||
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
|
||||
|
||||
def content_asset(self, content):
|
||||
if isinstance(content, TemplateContent):
|
||||
data = self.renderer.render(content.template, content.data, content.profile)
|
||||
return self.assets.put(data, content.template + ".jpg", "generated")
|
||||
if isinstance(content, URLContent):
|
||||
data = self.assets.fetch_url(content.url)
|
||||
elif isinstance(content, ImageContent) and content.asset_id:
|
||||
return self.assets.info(content.asset_id)
|
||||
elif isinstance(content, ImageContent) and content.base64:
|
||||
data = self.assets.decode(content.base64)
|
||||
else:
|
||||
raise FlowError(E.INVALID_REQUEST, "Content requires renderable image data")
|
||||
return self.assets.put(
|
||||
convert(data, content.fit, content.profile), content.name
|
||||
)
|
||||
|
||||
def top(self, device_id):
|
||||
return self.db.one(
|
||||
"SELECT * FROM overrides WHERE device_id=? AND 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))
|
||||
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)
|
||||
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:
|
||||
result["rendered_asset_id"] = asset["asset_id"]
|
||||
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):
|
||||
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):
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import Protocol
|
||||
|
||||
from pigway_frame_flow.models import Device, GalleryImage
|
||||
|
||||
|
||||
class FrameDeviceDriver(Protocol):
|
||||
def invalidate(self) -> None: ...
|
||||
def device(self, refresh: bool = False) -> Device: ...
|
||||
def get_current_image(self) -> int | None: ...
|
||||
def health(self) -> bool: ...
|
||||
def display_by_name(self, name: str): ...
|
||||
def images(self) -> list[GalleryImage]: ...
|
||||
def upload(self, path, name: str) -> int: ...
|
||||
def display(self, image_id: int): ...
|
||||
def delete(self, image_id: int): ...
|
||||
def download(self, image_id: int) -> bytes: ...
|
||||
|
||||
|
||||
class DriverRegistry(Protocol):
|
||||
drivers: dict[str, FrameDeviceDriver]
|
||||
|
||||
def get(self, id: str) -> FrameDeviceDriver: ...
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Hardened adapter over the MIT-0 guooscar 1.2.0 device/upload methods.
|
||||
|
||||
No UI, scheduler, renderer, environment lookup, or constructor cloud calls.
|
||||
Never retry a write; a timeout may have occurred after cloud acceptance.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from vendor import guooscar_protocol
|
||||
|
||||
BASE_URL = "https://api.kokonna.art/openapi"
|
||||
|
||||
|
||||
class SafeTransport:
|
||||
def __init__(self, key, session=None):
|
||||
self.key = key
|
||||
self.session = session or requests.Session()
|
||||
self.calls: deque[float] = deque()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def request(self, endpoint, payload=None, write=False, binary=False):
|
||||
if not self.key:
|
||||
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
|
||||
with self.lock:
|
||||
current = time.monotonic()
|
||||
while self.calls and current - self.calls[0] >= 60:
|
||||
self.calls.popleft()
|
||||
# Reserve capacity beneath the official 20/minute ceiling.
|
||||
if len(self.calls) >= 18:
|
||||
raise FlowError(E.RATE_LIMITED, "Device request budget exhausted", 429)
|
||||
self.calls.append(current)
|
||||
url = BASE_URL + endpoint
|
||||
try:
|
||||
response = self.session.request(
|
||||
"GET" if binary else "POST",
|
||||
url,
|
||||
headers={}
|
||||
if binary
|
||||
else {
|
||||
"Authorization": "Bearer " + self.key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=None if binary else payload or {},
|
||||
timeout=(5, 30),
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Cloud write outcome is unknown"
|
||||
if write
|
||||
else "Device cloud is unavailable",
|
||||
502,
|
||||
) from None
|
||||
if response.status_code != 200:
|
||||
code = (
|
||||
E.RATE_LIMITED
|
||||
if response.status_code == 429
|
||||
else (
|
||||
E.WRITE_RESULT_UNKNOWN
|
||||
if write and response.status_code >= 500
|
||||
else E.CLOUD_UNAVAILABLE
|
||||
)
|
||||
)
|
||||
raise FlowError(
|
||||
code,
|
||||
"Device cloud request failed",
|
||||
429 if code == E.RATE_LIMITED else 502,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
try:
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError()
|
||||
# Cloud errors may echo credentials: never return raw messages.
|
||||
if value.get("success") is False or (
|
||||
"message" in value and "id" not in value
|
||||
):
|
||||
raise ValueError()
|
||||
return value
|
||||
except ValueError:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
) from None
|
||||
|
||||
|
||||
class KokonnaClient:
|
||||
def __init__(self, key, write_enabled=False, session=None):
|
||||
self.transport = SafeTransport(key, session)
|
||||
self.write_enabled = write_enabled
|
||||
|
||||
def guard(self):
|
||||
if not self.write_enabled:
|
||||
raise FlowError(
|
||||
E.WRITE_DISABLED,
|
||||
"Real device writes require explicit authorization",
|
||||
403,
|
||||
)
|
||||
|
||||
def get_device(self):
|
||||
return guooscar_protocol.device(self.transport)
|
||||
|
||||
def list_images(self):
|
||||
return self.transport.request("/listImages", {})
|
||||
|
||||
def upload(self, path, name):
|
||||
self.guard()
|
||||
value = guooscar_protocol.upload(self.transport, path.read_bytes(), name)
|
||||
if not isinstance(value.get("id"), int) or value["id"] <= 0:
|
||||
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Upload ID not confirmed", 502)
|
||||
return value["id"]
|
||||
|
||||
def display(self, image_id):
|
||||
self.guard()
|
||||
return self.transport.request(
|
||||
"/displayImageById", {"imageId": image_id}, write=True
|
||||
)
|
||||
|
||||
def delete(self, image_id):
|
||||
self.guard()
|
||||
return self.transport.request("/deleteImage", {"imageId": image_id}, write=True)
|
||||
|
||||
def download(self, image_id):
|
||||
return self.transport.request(
|
||||
f"/image/{quote(self.transport.key, safe='')}/{image_id}", binary=True
|
||||
)
|
||||
|
||||
def display_by_name(self, name):
|
||||
self.guard()
|
||||
return self.transport.request(
|
||||
"/displayImageByName", {"imageName": name}, write=True
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
from pigway_frame_flow.models import (
|
||||
CurrentContent,
|
||||
Device,
|
||||
DisplayGeometry,
|
||||
GalleryImage,
|
||||
Power,
|
||||
)
|
||||
|
||||
|
||||
class KokonnaDriver:
|
||||
def __init__(self, client, device_id, sanitize=lambda v: v):
|
||||
self.client, self.device_id, self.sanitize = client, device_id, sanitize
|
||||
self.cached: Device | None = None
|
||||
self.cached_at = 0.0
|
||||
|
||||
def device(self, refresh=False):
|
||||
import time
|
||||
|
||||
if not refresh and self.cached and time.monotonic() - self.cached_at < 60:
|
||||
return self.cached
|
||||
d = self.sanitize(self.client.get_device())
|
||||
result = Device(
|
||||
id=self.device_id,
|
||||
name=d.get("nickname", "KoKonna"),
|
||||
status="online" if d.get("online") else "offline",
|
||||
model=d.get("model", "13.3-inch"),
|
||||
model_code=d.get("modelCode", "B101"),
|
||||
firmware=d.get("firmware"),
|
||||
display=DisplayGeometry(
|
||||
**{
|
||||
"width": d.get("screenWidth", 1200),
|
||||
"height": d.get("screenHeight", 1600),
|
||||
"rotation": d.get("screenRotate", 270),
|
||||
}
|
||||
),
|
||||
power=Power(
|
||||
**{
|
||||
"battery_percent": d.get("batteryLevel"),
|
||||
"charging": bool(d.get("isCharging")),
|
||||
}
|
||||
),
|
||||
current_content=CurrentContent(
|
||||
**{
|
||||
"id": d.get("imageId"),
|
||||
"cover_id": d.get("coverId"),
|
||||
"synced": bool(d.get("synced")),
|
||||
}
|
||||
),
|
||||
slideshow={
|
||||
"mode": d.get("switchType"),
|
||||
"interval_minutes": d.get("switchMinute"),
|
||||
"members_available": False,
|
||||
"editable": False,
|
||||
},
|
||||
storage_used_bytes=d.get("sdUsedSize"),
|
||||
model_verified=bool(d.get("model") and d.get("modelCode")),
|
||||
last_seen_at=d.get("lastHeartbeat"),
|
||||
timezone=d.get("timezone", "Asia/Shanghai"),
|
||||
)
|
||||
|
||||
self.cached = result
|
||||
self.cached_at = time.monotonic()
|
||||
return result
|
||||
|
||||
def get_current_image(self):
|
||||
return self.device().current_content.id
|
||||
|
||||
def health(self):
|
||||
return bool(self.device())
|
||||
|
||||
def invalidate(self):
|
||||
self.cached_at = 0.0
|
||||
|
||||
def display_by_name(self, name):
|
||||
return self.client.display_by_name(name)
|
||||
|
||||
def images(self):
|
||||
data = self.sanitize(self.client.list_images())
|
||||
return [
|
||||
GalleryImage(
|
||||
image_id=d["id"],
|
||||
width=d.get("width"),
|
||||
height=d.get("height"),
|
||||
size_bytes=d.get("size"),
|
||||
media_type=d.get("type"),
|
||||
name=d.get("name", "image"),
|
||||
created_at=d.get("createdAt"),
|
||||
current=bool(d.get("current")),
|
||||
url=f"/api/v1/devices/{self.device_id}/images/{d['id']}/file",
|
||||
)
|
||||
for d in data.get("list", [])
|
||||
]
|
||||
|
||||
def upload(self, path, name):
|
||||
return self.client.upload(path, name)
|
||||
|
||||
def display(self, image_id):
|
||||
return self.client.display(image_id)
|
||||
|
||||
def delete(self, image_id):
|
||||
return self.client.delete(image_id)
|
||||
|
||||
def download(self, image_id):
|
||||
return self.client.download(image_id)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Persistent mock implements the documented upload-immediately-activates behavior."""
|
||||
|
||||
import json
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.storage.database import now_iso
|
||||
|
||||
|
||||
class MockClient:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.path = root / "mock-device.json"
|
||||
if self.path.exists():
|
||||
self.state = json.loads(self.path.read_text())
|
||||
else:
|
||||
self.state = {
|
||||
"current": 1,
|
||||
"counter": 1,
|
||||
"images": [
|
||||
{"id": 1, "name": "Original Art.jpg", "createdAt": now_iso()}
|
||||
],
|
||||
}
|
||||
img = Image.new("RGB", (1200, 1600), "#efe2ca")
|
||||
img.save(root / "mock-1.jpg")
|
||||
self.save()
|
||||
self.online = True
|
||||
self.synced = True
|
||||
self.uploads = 0
|
||||
self.displays = []
|
||||
|
||||
def save(self):
|
||||
temporary = self.path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(self.state))
|
||||
temporary.replace(self.path)
|
||||
|
||||
def get_device(self):
|
||||
return {
|
||||
"nickname": "客厅相框",
|
||||
"model": "13.3-inch",
|
||||
"modelCode": "B101",
|
||||
"firmware": "MOCK-B101",
|
||||
"online": self.online,
|
||||
"lastHeartbeat": now_iso(),
|
||||
"batteryLevel": 40,
|
||||
"isCharging": False,
|
||||
"screenWidth": 1200,
|
||||
"screenHeight": 1600,
|
||||
"screenRotate": 270,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"switchType": "queue",
|
||||
"switchMinute": 60,
|
||||
"sdUsedSize": 123456,
|
||||
"imageId": self.state["current"],
|
||||
"coverId": None,
|
||||
"synced": self.synced,
|
||||
}
|
||||
|
||||
def list_images(self):
|
||||
return {
|
||||
"total": len(self.state["images"]),
|
||||
"list": [
|
||||
dict(x, current=x["id"] == self.state["current"])
|
||||
for x in reversed(self.state["images"])
|
||||
],
|
||||
}
|
||||
|
||||
def upload(self, path, name):
|
||||
self.uploads += 1
|
||||
self.state["counter"] += 1
|
||||
image_id = self.state["counter"]
|
||||
(self.root / f"mock-{image_id}.jpg").write_bytes(path.read_bytes())
|
||||
self.state["images"].append(
|
||||
{"id": image_id, "name": name, "createdAt": now_iso()}
|
||||
)
|
||||
self.state["current"] = image_id
|
||||
self.save()
|
||||
return image_id
|
||||
|
||||
def display(self, image_id):
|
||||
if image_id not in [x["id"] for x in self.state["images"]]:
|
||||
raise FlowError(E.NOT_FOUND, "Image not found", 404)
|
||||
self.displays.append(image_id)
|
||||
self.state["current"] = image_id
|
||||
self.save()
|
||||
|
||||
def delete(self, image_id):
|
||||
self.state["images"] = [x for x in self.state["images"] if x["id"] != image_id]
|
||||
if self.state["current"] == image_id:
|
||||
self.state["current"] = (
|
||||
self.state["images"][-1]["id"] if self.state["images"] else None
|
||||
)
|
||||
(self.root / f"mock-{image_id}.jpg").unlink(missing_ok=True)
|
||||
self.save()
|
||||
|
||||
def download(self, image_id):
|
||||
path = self.root / f"mock-{image_id}.jpg"
|
||||
if not path.exists():
|
||||
raise FlowError(E.NOT_FOUND, "Image not found", 404)
|
||||
return path.read_bytes()
|
||||
|
||||
def display_by_name(self, name):
|
||||
matches = [x for x in self.state["images"] if x["name"] == name]
|
||||
if not matches:
|
||||
raise FlowError(E.NOT_FOUND, "Image not found", 404)
|
||||
return self.display(matches[-1]["id"])
|
||||
@@ -0,0 +1,26 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
INVALID_REQUEST = "INVALID_REQUEST"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
FORBIDDEN = "FORBIDDEN"
|
||||
DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND"
|
||||
DEVICE_OFFLINE = "DEVICE_OFFLINE"
|
||||
UNSUPPORTED_CAPABILITY = "UNSUPPORTED_CAPABILITY"
|
||||
RENDER_FAILED = "RENDER_FAILED"
|
||||
UPLOAD_FAILED = "UPLOAD_FAILED"
|
||||
WRITE_RESULT_UNKNOWN = "WRITE_RESULT_UNKNOWN"
|
||||
RATE_LIMITED = "RATE_LIMITED"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
CLOUD_UNAVAILABLE = "CLOUD_UNAVAILABLE"
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
CONFLICT = "CONFLICT"
|
||||
CONFIG_REQUIRED = "CONFIG_REQUIRED"
|
||||
WRITE_DISABLED = "WRITE_DISABLED"
|
||||
|
||||
|
||||
class FlowError(Exception):
|
||||
def __init__(self, code: ErrorCode, message: str, status: int = 400):
|
||||
self.code, self.message, self.status = code, message, status
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1,3 @@
|
||||
from pigway_frame_flow.api.app import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,347 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class Fit(StrEnum):
|
||||
contain = "contain"
|
||||
cover = "cover"
|
||||
smart = "smart"
|
||||
|
||||
|
||||
class Profile(StrEnum):
|
||||
color = "color"
|
||||
grayscale = "grayscale"
|
||||
|
||||
|
||||
class ImageContent(StrictModel):
|
||||
type: Literal["image"]
|
||||
image_id: int | None = Field(default=None, gt=0)
|
||||
image_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
asset_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
base64: str | None = Field(default=None, max_length=28_000_000)
|
||||
name: str = Field(default="image.jpg", min_length=1, max_length=120)
|
||||
fit: Fit = Fit.contain
|
||||
profile: Profile = Profile.color
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_source(self):
|
||||
if (
|
||||
sum(
|
||||
x is not None
|
||||
for x in [self.image_id, self.image_name, self.asset_id, self.base64]
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise ValueError("Specify exactly one image_id, asset_id, or base64")
|
||||
return self
|
||||
|
||||
|
||||
class URLContent(StrictModel):
|
||||
type: Literal["image_url"]
|
||||
url: str = Field(max_length=2048)
|
||||
name: str = Field(default="image.jpg", max_length=120)
|
||||
fit: Fit = Fit.contain
|
||||
profile: Profile = Profile.color
|
||||
|
||||
|
||||
class TemplateContent(StrictModel):
|
||||
type: Literal["template"]
|
||||
template: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
profile: Profile = Profile.color
|
||||
|
||||
|
||||
class FutureContent(StrictModel):
|
||||
type: Literal["html", "markdown"]
|
||||
data: str = ""
|
||||
|
||||
|
||||
Content = Annotated[
|
||||
ImageContent | URLContent | TemplateContent | FutureContent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class Options(StrictModel):
|
||||
duration: int = Field(default=0, ge=0, le=604800)
|
||||
restore_previous: bool = False
|
||||
priority: int = Field(default=50, ge=0, le=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def restoration(self):
|
||||
if self.restore_previous and self.duration == 0:
|
||||
raise ValueError("restore_previous requires a positive duration")
|
||||
return self
|
||||
|
||||
|
||||
class Source(StrictModel):
|
||||
client: str | None = Field(default=None, max_length=80)
|
||||
request_id: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class DisplayRequest(StrictModel):
|
||||
device_id: str = "living-room"
|
||||
content: Content
|
||||
options: Options = Field(default_factory=Options)
|
||||
source: Source = Field(default_factory=Source)
|
||||
|
||||
|
||||
class Accepted(BaseModel):
|
||||
operation_id: str
|
||||
job_id: str
|
||||
status: Literal["accepted"] = "accepted"
|
||||
|
||||
|
||||
class OperationStatus(StrEnum):
|
||||
QUEUED = "QUEUED"
|
||||
RENDERING = "RENDERING"
|
||||
UPLOADING = "UPLOADING"
|
||||
SYNCING = "SYNCING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ErrorBody(BaseModel):
|
||||
code: ErrorCode
|
||||
message: str
|
||||
request_id: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: ErrorBody
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
image_id: int | None = None
|
||||
asset_id: str | None = None
|
||||
rendered_asset_id: str | None = None
|
||||
url: str | None = None
|
||||
deferred: bool = False
|
||||
|
||||
|
||||
class Operation(BaseModel):
|
||||
operation_id: str
|
||||
status: OperationStatus
|
||||
device_id: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
result: OperationResult | None = None
|
||||
error: ErrorBody | None = None
|
||||
request_id: str
|
||||
client_id: str
|
||||
action: str
|
||||
|
||||
|
||||
class Capabilities(BaseModel):
|
||||
upload: bool = True
|
||||
gallery: bool = True
|
||||
delete: bool = True
|
||||
switch: bool = True
|
||||
remote_refresh: bool = True
|
||||
battery: bool = True
|
||||
color: bool = True
|
||||
cloud_upload_without_display: bool = False
|
||||
|
||||
|
||||
class DisplayGeometry(BaseModel):
|
||||
width: int
|
||||
height: int
|
||||
rotation: int
|
||||
color_mode: Profile = Profile.color
|
||||
|
||||
|
||||
class Power(BaseModel):
|
||||
battery_percent: int | None
|
||||
charging: bool
|
||||
|
||||
|
||||
class CurrentContent(BaseModel):
|
||||
type: Literal["image"] = "image"
|
||||
id: int | None
|
||||
cover_id: int | None
|
||||
synced: bool
|
||||
|
||||
|
||||
class Device(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
driver: Literal["kokonna"] = "kokonna"
|
||||
status: Literal["online", "offline"]
|
||||
model: str
|
||||
model_code: str
|
||||
firmware: str | None
|
||||
display: DisplayGeometry
|
||||
power: Power
|
||||
current_content: CurrentContent
|
||||
last_seen_at: str | None
|
||||
timezone: str
|
||||
capabilities: Capabilities = Field(default_factory=Capabilities)
|
||||
slideshow: dict[str, Any] = Field(default_factory=dict)
|
||||
storage_used_bytes: int | None = None
|
||||
model_verified: bool = False
|
||||
|
||||
|
||||
class GalleryImage(BaseModel):
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
size_bytes: int | None = None
|
||||
media_type: str | None = None
|
||||
image_id: int
|
||||
name: str
|
||||
created_at: str | None
|
||||
current: bool
|
||||
url: str
|
||||
|
||||
|
||||
class Schedule(StrictModel):
|
||||
type: Literal["cron", "daily", "weekdays", "weekends", "date", "temporary"]
|
||||
cron: str | None = None
|
||||
time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
|
||||
at: datetime | None = None
|
||||
|
||||
|
||||
class RestoreContent(StrictModel):
|
||||
type: Literal["restore"]
|
||||
|
||||
|
||||
class JobInput(StrictModel):
|
||||
enabled: bool = True
|
||||
device_id: str = "living-room"
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
schedule: Schedule
|
||||
source: Content | RestoreContent
|
||||
display_duration: int = Field(default=0, ge=0, le=604800)
|
||||
restore_previous: bool = False
|
||||
priority: int = Field(default=50, ge=0, le=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_options(self):
|
||||
Options(
|
||||
duration=self.display_duration,
|
||||
restore_previous=self.restore_previous,
|
||||
priority=self.priority,
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class Job(JobInput):
|
||||
id: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_run: str | None = None
|
||||
last_result: str | None = None
|
||||
next_run: str | None = None
|
||||
|
||||
|
||||
class TemplateInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
width: int = 1200
|
||||
height: int = 1600
|
||||
fields: list[str]
|
||||
defaults: dict[str, Any]
|
||||
|
||||
|
||||
class TemplateConfig(StrictModel):
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class History(BaseModel):
|
||||
id: int
|
||||
client_id: str
|
||||
request_id: str
|
||||
operation_id: str | None
|
||||
device_id: str
|
||||
action: str
|
||||
result: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
version: str
|
||||
mock: bool
|
||||
write_enabled: bool
|
||||
api_key_configured: bool
|
||||
api_token_configured: bool
|
||||
timezone: str
|
||||
device_id: str
|
||||
weather_provider: str
|
||||
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.0"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
status: str
|
||||
checks: dict[str, bool]
|
||||
|
||||
|
||||
class Asset(BaseModel):
|
||||
asset_id: str
|
||||
name: str
|
||||
url: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class DeleteRequest(StrictModel):
|
||||
confirm_current: bool = False
|
||||
|
||||
|
||||
class RestoreRequest(StrictModel):
|
||||
device_id: str = "living-room"
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.0"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
|
||||
class PlaylistInput(StrictModel):
|
||||
device_id: str = "living-room"
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
image_ids: list[int] = Field(min_length=1, max_length=100)
|
||||
interval_minutes: int = Field(default=60, ge=5, le=10080)
|
||||
mode: Literal["queue", "random"] = "queue"
|
||||
enabled: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ids(self):
|
||||
if any(x <= 0 for x in self.image_ids) or len(set(self.image_ids)) != len(
|
||||
self.image_ids
|
||||
):
|
||||
raise ValueError("Image IDs must be positive and unique")
|
||||
return self
|
||||
|
||||
|
||||
class Playlist(PlaylistInput):
|
||||
id: str
|
||||
position: int
|
||||
next_run: str | None
|
||||
|
||||
|
||||
class ReconcileRequest(StrictModel):
|
||||
acknowledge: bool = False
|
||||
|
||||
|
||||
class Reconciliation(BaseModel):
|
||||
operation_id: str
|
||||
current_image_id: int | None
|
||||
expected_image_id: int | None
|
||||
cloud_images: list[GalleryImage]
|
||||
resolved: bool
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class WeatherProvider(Protocol):
|
||||
def get_current(self) -> dict: ...
|
||||
def get_forecast(self) -> list[dict]: ...
|
||||
|
||||
|
||||
class MockWeatherProvider:
|
||||
def get_current(self):
|
||||
return {
|
||||
"location": "上海",
|
||||
"temperature": "--",
|
||||
"summary": "天气演示数据 · 尚未连接实时天气源",
|
||||
"mock": True,
|
||||
}
|
||||
|
||||
def get_forecast(self):
|
||||
return []
|
||||
@@ -0,0 +1,162 @@
|
||||
import base64
|
||||
import ipaddress
|
||||
import socket
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.storage.database import now_iso
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = 25_000_000
|
||||
|
||||
|
||||
def convert(data, fit="contain", profile="color"):
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
||||
raise ValueError()
|
||||
if source.width * source.height > 25_000_000:
|
||||
raise ValueError()
|
||||
img = ImageOps.exif_transpose(source).convert("RGBA")
|
||||
canvas = Image.new("RGBA", img.size, "white")
|
||||
canvas.alpha_composite(img)
|
||||
img = canvas.convert("RGB")
|
||||
if fit == "contain":
|
||||
result = ImageOps.pad(
|
||||
img, (1200, 1600), method=Image.Resampling.LANCZOS, color="white"
|
||||
)
|
||||
else:
|
||||
center = (0.5, 0.5)
|
||||
if fit == "smart":
|
||||
# Lightweight edge saliency: slide an aspect-correct window
|
||||
# over a thumbnail; retain the region with most detail.
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((240, 240))
|
||||
edges = thumb.convert("L").filter(ImageFilter.FIND_EDGES)
|
||||
w, h = edges.size
|
||||
cw, ch = (int(h * 0.75), h) if w / h > 0.75 else (w, int(w / 0.75))
|
||||
choices = []
|
||||
for step in range(11):
|
||||
x, y = int((w - cw) * step / 10), int((h - ch) * step / 10)
|
||||
score = ImageStat.Stat(edges.crop((x, y, x + cw, y + ch))).sum[
|
||||
0
|
||||
]
|
||||
# Favor centered crops in ties, suppress border artifacts.
|
||||
choices.append((score - abs(step - 5) * 0.01, x, y))
|
||||
_, x, y = max(choices)
|
||||
center = (
|
||||
(x / (w - cw)) if w != cw else 0.5,
|
||||
(y / (h - ch)) if h != ch else 0.5,
|
||||
)
|
||||
result = ImageOps.fit(
|
||||
img, (1200, 1600), method=Image.Resampling.LANCZOS, centering=center
|
||||
)
|
||||
if profile == "grayscale":
|
||||
result = ImageOps.grayscale(result).convert("RGB")
|
||||
out = BytesIO()
|
||||
result.save(out, "JPEG", quality=93, optimize=True)
|
||||
return out.getvalue()
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Unsupported or invalid image") from None
|
||||
|
||||
|
||||
class Assets:
|
||||
def __init__(self, db, settings):
|
||||
self.db, self.settings = db, settings
|
||||
|
||||
def put(self, data, name="image.jpg", kind="images"):
|
||||
from pathlib import Path
|
||||
|
||||
name = Path(name).stem[:110] + ".jpg"
|
||||
asset_id = uuid4().hex
|
||||
(self.settings.data_dir / kind / (asset_id + ".jpg")).write_bytes(data)
|
||||
self.db.execute(
|
||||
"INSERT INTO assets VALUES(?,?,?,?)", (asset_id, name, kind, now_iso())
|
||||
)
|
||||
return self.info(asset_id)
|
||||
|
||||
def info(self, asset_id):
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
||||
return {
|
||||
"asset_id": row["id"],
|
||||
"name": row["name"],
|
||||
"url": f"/api/v1/assets/{row['id']}",
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
def path(self, asset_id):
|
||||
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Asset not found", 404)
|
||||
return self.settings.data_dir / row["kind"] / (row["id"] + ".jpg")
|
||||
|
||||
def decode(self, value):
|
||||
try:
|
||||
data = base64.b64decode(value, validate=True)
|
||||
except ValueError:
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid base64 image") from None
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
return data
|
||||
|
||||
def fetch_url(self, url):
|
||||
parsed = urlsplit(url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.port not in (None, 443)
|
||||
):
|
||||
raise FlowError(E.INVALID_REQUEST, "Image URL requires HTTPS on port 443")
|
||||
if parsed.hostname.lower() not in self.settings.image_url_hosts:
|
||||
raise FlowError(E.FORBIDDEN, "Image host is not allowed", 403)
|
||||
try:
|
||||
addresses = socket.getaddrinfo(
|
||||
parsed.hostname, 443, type=socket.SOCK_STREAM
|
||||
)
|
||||
if not addresses or any(
|
||||
not ipaddress.ip_address(x[4][0]).is_global for x in addresses
|
||||
):
|
||||
raise FlowError(
|
||||
E.FORBIDDEN, "Private image addresses are forbidden", 403
|
||||
)
|
||||
# Pin the validated resolution for this request, including TLS SNI,
|
||||
# instead of performing a second DNS lookup (rebinding).
|
||||
import http.client
|
||||
import ssl
|
||||
|
||||
ip = str(addresses[0][4][0])
|
||||
|
||||
class PinnedHTTPS(http.client.HTTPSConnection):
|
||||
def connect(self):
|
||||
sock = socket.create_connection((ip, 443), timeout=10)
|
||||
self.sock = ssl.create_default_context().wrap_socket(
|
||||
sock, server_hostname=parsed.hostname
|
||||
)
|
||||
|
||||
connection = PinnedHTTPS(parsed.hostname, timeout=10)
|
||||
try:
|
||||
connection.request(
|
||||
"GET",
|
||||
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Image URL returned an invalid response"
|
||||
)
|
||||
data = response.read(self.settings.max_bytes + 1)
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||
return data
|
||||
finally:
|
||||
connection.close()
|
||||
except (OSError, ValueError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Unable to fetch image URL") from None
|
||||
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
class TemplateRenderer:
|
||||
def __init__(self, db, settings, weather, assets):
|
||||
self.db, self.settings, self.weather, self.assets = (
|
||||
db,
|
||||
settings,
|
||||
weather,
|
||||
assets,
|
||||
)
|
||||
self.root = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
def info(self, template_id):
|
||||
path = self.root / (template_id + ".json")
|
||||
if not path.exists() or path.parent != self.root:
|
||||
raise FlowError(E.NOT_FOUND, "Template not found", 404)
|
||||
spec = json.loads(path.read_text())
|
||||
config = self.db.one(
|
||||
"SELECT payload FROM template_config WHERE template_id=?", (template_id,)
|
||||
)
|
||||
if config:
|
||||
spec["defaults"].update(json.loads(config["payload"]))
|
||||
return spec
|
||||
|
||||
def list(self):
|
||||
return [self.info(p.stem) for p in sorted(self.root.glob("*.json"))]
|
||||
|
||||
def font(self, size):
|
||||
for path in [
|
||||
self.settings.font_path,
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
]:
|
||||
if Path(path).is_file():
|
||||
return ImageFont.truetype(path, size)
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
def render(self, template_id, data, profile="color"):
|
||||
spec = self.info(template_id)
|
||||
conf = self.db.one(
|
||||
"SELECT payload FROM template_config WHERE template_id=?", (template_id,)
|
||||
)
|
||||
values = dict(spec["defaults"])
|
||||
if conf:
|
||||
values.update(json.loads(conf["payload"]))
|
||||
values.update(data)
|
||||
current = datetime.now(ZoneInfo(self.settings.timezone))
|
||||
values.setdefault("date", current.strftime("%Y-%m-%d"))
|
||||
values.setdefault(
|
||||
"weekday",
|
||||
[
|
||||
"星期一 / Monday",
|
||||
"星期二 / Tuesday",
|
||||
"星期三 / Wednesday",
|
||||
"星期四 / Thursday",
|
||||
"星期五 / Friday",
|
||||
"星期六 / Saturday",
|
||||
"星期日 / Sunday",
|
||||
][current.weekday()],
|
||||
)
|
||||
values.setdefault("weather", self.weather.get_current())
|
||||
values["updated"] = current.strftime("%Y-%m-%d %H:%M")
|
||||
img = Image.new("RGB", (1200, 1600), "white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent = "#184d42" if profile == "color" else "#000000"
|
||||
|
||||
def text(value, x, y, width, height, size):
|
||||
font = self.font(size)
|
||||
lines, line = [], ""
|
||||
for char in str(value)[:3000]:
|
||||
if char == "\n" or draw.textlength(line + char, font=font) > width:
|
||||
lines.append(line)
|
||||
line = "" if char == "\n" else char
|
||||
else:
|
||||
line += char
|
||||
lines.append(line)
|
||||
maxlines = max(1, height // (size + 14))
|
||||
for n, line in enumerate(lines[:maxlines]):
|
||||
if n == maxlines - 1 and len(lines) > maxlines:
|
||||
line = line[:-2] + "…"
|
||||
draw.text(
|
||||
(x, y + n * (size + 14)),
|
||||
line,
|
||||
font=font,
|
||||
fill=accent if size >= 60 else "black",
|
||||
)
|
||||
|
||||
for block in spec["blocks"]:
|
||||
x, y, w, h = block["box"]
|
||||
if block["kind"] == "rule":
|
||||
draw.rectangle((x, y, x + w, y + h), fill=accent)
|
||||
elif block["kind"] == "photo":
|
||||
asset_id = values.get("photo_id")
|
||||
if asset_id:
|
||||
with Image.open(self.assets.path(asset_id)) as photo:
|
||||
img.paste(ImageOps.fit(photo.convert("RGB"), (w, h)), (x, y))
|
||||
else:
|
||||
draw.rectangle((x, y, x + w, y + h), outline="black", width=5)
|
||||
text(
|
||||
"家庭照片 / Family photo",
|
||||
x + 35,
|
||||
y + h // 2 - 30,
|
||||
w - 70,
|
||||
100,
|
||||
36,
|
||||
)
|
||||
else:
|
||||
value = values.get(block.get("field"), block.get("text", ""))
|
||||
if isinstance(value, list):
|
||||
value = "\n".join(
|
||||
"• "
|
||||
+ (str(v.get("title", "")) if isinstance(v, dict) else str(v))
|
||||
for v in value[:10]
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
value = " · ".join(str(v) for k, v in value.items() if k != "mock")
|
||||
text(value, x, y, w, h, block.get("size", 40))
|
||||
if profile == "grayscale":
|
||||
img = ImageOps.grayscale(img).convert("RGB")
|
||||
out = BytesIO()
|
||||
img.save(out, "JPEG", quality=93)
|
||||
return out.getvalue()
|
||||
@@ -0,0 +1,273 @@
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import (
|
||||
DisplayRequest,
|
||||
ImageContent,
|
||||
JobInput,
|
||||
Options,
|
||||
PlaylistInput,
|
||||
RestoreContent,
|
||||
)
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
def next_run(schedule, after=None):
|
||||
after = after or datetime.now(UTC)
|
||||
if schedule.type in ("date", "temporary"):
|
||||
if not schedule.at or schedule.at.tzinfo is None:
|
||||
raise FlowError(E.INVALID_REQUEST, "One-time date must include timezone")
|
||||
return schedule.at.astimezone(UTC).isoformat() if schedule.at > after else None
|
||||
if schedule.type == "cron":
|
||||
expression = schedule.cron or ""
|
||||
if len(expression.split()) != 5 or not croniter.is_valid(expression):
|
||||
raise FlowError(E.INVALID_REQUEST, "Cron requires five valid fields")
|
||||
else:
|
||||
if not schedule.time:
|
||||
raise FlowError(E.INVALID_REQUEST, "Daily schedule requires HH:MM")
|
||||
hour, minute = schedule.time.split(":")
|
||||
day = (
|
||||
"1-5"
|
||||
if schedule.type == "weekdays"
|
||||
else ("6,0" if schedule.type == "weekends" else "*")
|
||||
)
|
||||
expression = f"{minute} {hour} * * {day}"
|
||||
return (
|
||||
croniter(expression, after.astimezone(ZoneInfo("Asia/Shanghai")))
|
||||
.get_next(datetime)
|
||||
.astimezone(UTC)
|
||||
.isoformat()
|
||||
)
|
||||
|
||||
|
||||
class Scheduler:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
self.stop_event = threading.Event()
|
||||
self.thread = None
|
||||
self.tick_lock = threading.Lock()
|
||||
|
||||
def jobs(self):
|
||||
rows = [
|
||||
dict(
|
||||
json.loads(x["payload"]),
|
||||
id=x["id"],
|
||||
created_at=x["created_at"],
|
||||
updated_at=x["updated_at"],
|
||||
last_run=x["last_run"],
|
||||
last_result=x["last_result"],
|
||||
next_run=x["next_run"],
|
||||
)
|
||||
for x in self.db.all("SELECT * FROM jobs ORDER BY created_at DESC")
|
||||
]
|
||||
|
||||
for row in rows:
|
||||
if row["last_result"]:
|
||||
operation = self.db.one(
|
||||
"SELECT status FROM operations WHERE id=?", (row["last_result"],)
|
||||
)
|
||||
if operation:
|
||||
row["last_result"] = operation["status"]
|
||||
return rows
|
||||
|
||||
def save_job(self, job, id=None):
|
||||
self.service.registry.get(job.device_id)
|
||||
if job.source.type != "restore":
|
||||
self.service.validate(
|
||||
DisplayRequest(device_id=job.device_id, content=job.source),
|
||||
writing=False,
|
||||
)
|
||||
upcoming = next_run(job.schedule)
|
||||
if job.enabled and upcoming is None:
|
||||
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
|
||||
id = id or uuid4().hex
|
||||
now = now_iso()
|
||||
self.db.execute(
|
||||
"INSERT INTO jobs(id,payload,enabled,next_run,created_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,enabled=excluded.enabled,next_run=excluded.next_run,updated_at=excluded.updated_at",
|
||||
(
|
||||
id,
|
||||
encode(self.service.settings.sanitize(job.model_dump(mode="json"))),
|
||||
int(job.enabled),
|
||||
upcoming,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return next(x for x in self.jobs() if x["id"] == id)
|
||||
|
||||
def playlists(self):
|
||||
return [
|
||||
dict(
|
||||
json.loads(x["payload"]),
|
||||
id=x["id"],
|
||||
position=x["position"],
|
||||
next_run=x["next_run"],
|
||||
)
|
||||
for x in self.db.all("SELECT * FROM playlists")
|
||||
]
|
||||
|
||||
def save_playlist(self, playlist, id=None):
|
||||
self.service.registry.get(playlist.device_id)
|
||||
for existing in self.playlists():
|
||||
if (
|
||||
existing["enabled"]
|
||||
and playlist.enabled
|
||||
and existing["device_id"] == playlist.device_id
|
||||
and existing["id"] != id
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Only one Frame Flow playlist may run per device", 409
|
||||
)
|
||||
id = id or uuid4().hex
|
||||
self.db.execute(
|
||||
"INSERT INTO playlists VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,next_run=excluded.next_run",
|
||||
(
|
||||
id,
|
||||
encode(playlist.model_dump()),
|
||||
-1,
|
||||
now_iso() if playlist.enabled else None,
|
||||
),
|
||||
)
|
||||
return next(x for x in self.playlists() if x["id"] == id)
|
||||
|
||||
def tick(self, current=None):
|
||||
current = current or datetime.now(UTC)
|
||||
with self.tick_lock:
|
||||
for row in self.db.all(
|
||||
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
|
||||
(current.isoformat(),),
|
||||
):
|
||||
job = JobInput.model_validate_json(row["payload"])
|
||||
slot = row["next_run"]
|
||||
if self.db.one(
|
||||
"SELECT * FROM job_runs WHERE job_id=? AND scheduled_at=?",
|
||||
(row["id"], slot),
|
||||
):
|
||||
continue
|
||||
# Skip overdue tasks (>5 minutes): avoid an unexpected artwork
|
||||
# overwrite after downtime. One-time tasks are retired too.
|
||||
late = (current - datetime.fromisoformat(slot)).total_seconds() > 300
|
||||
operation_id = None
|
||||
if not late:
|
||||
action = "restore" if job.source.type == "restore" else "display"
|
||||
payload = (
|
||||
{}
|
||||
if isinstance(job.source, RestoreContent)
|
||||
else DisplayRequest(
|
||||
device_id=job.device_id,
|
||||
content=job.source,
|
||||
options=Options(
|
||||
duration=job.display_duration,
|
||||
restore_previous=job.restore_previous,
|
||||
priority=job.priority,
|
||||
),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
operation_id = self.service.submit(
|
||||
action,
|
||||
payload,
|
||||
job.device_id,
|
||||
"scheduler",
|
||||
"schedule-" + uuid4().hex,
|
||||
"job-" + row["id"] + "-" + slot,
|
||||
)
|
||||
following = next_run(job.schedule, current)
|
||||
enabled = bool(following)
|
||||
updated = job.model_copy(update={"enabled": enabled}).model_dump(
|
||||
mode="json"
|
||||
)
|
||||
with self.db.transaction() as tx:
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO job_runs VALUES(?,?,?)",
|
||||
(row["id"], slot, operation_id),
|
||||
)
|
||||
tx.execute(
|
||||
"UPDATE jobs SET payload=?,enabled=?,next_run=?,last_run=?,last_result=? WHERE id=?",
|
||||
(
|
||||
encode(updated),
|
||||
int(enabled),
|
||||
following,
|
||||
now_iso(),
|
||||
"SKIPPED_OVERDUE" if late else operation_id,
|
||||
row["id"],
|
||||
),
|
||||
)
|
||||
for row in self.db.all(
|
||||
"SELECT * FROM playlists WHERE next_run<=?", (current.isoformat(),)
|
||||
):
|
||||
p = PlaylistInput.model_validate_json(row["payload"])
|
||||
if (
|
||||
not p.enabled
|
||||
or self.service.top(p.device_id)
|
||||
or self.service.blocked(p.device_id)
|
||||
):
|
||||
continue
|
||||
if (
|
||||
not self.service.settings.mock
|
||||
and not self.service.settings.write_enabled
|
||||
):
|
||||
continue
|
||||
pos = (
|
||||
random.randrange(len(p.image_ids))
|
||||
if p.mode == "random"
|
||||
else (row["position"] + 1) % len(p.image_ids)
|
||||
)
|
||||
request = DisplayRequest(
|
||||
device_id=p.device_id,
|
||||
content=ImageContent(type="image", image_id=p.image_ids[pos]),
|
||||
options=Options(priority=0),
|
||||
)
|
||||
self.service.submit(
|
||||
"display",
|
||||
request.model_dump(mode="json"),
|
||||
p.device_id,
|
||||
"scheduler",
|
||||
"playlist-" + uuid4().hex,
|
||||
"playlist-" + row["id"] + "-" + row["next_run"],
|
||||
)
|
||||
following = (
|
||||
current + timedelta(minutes=p.interval_minutes)
|
||||
).isoformat()
|
||||
self.db.execute(
|
||||
"UPDATE playlists SET position=?,next_run=? WHERE id=?",
|
||||
(pos, following, row["id"]),
|
||||
)
|
||||
|
||||
def start(self):
|
||||
self.thread = threading.Thread(
|
||||
target=self.run, name="frame-flow-worker", daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
def run(self):
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
self.tick()
|
||||
self.service.expire()
|
||||
processed = self.service.process_one()
|
||||
self.service.sync()
|
||||
except Exception:
|
||||
logging.getLogger("frame-flow").error(
|
||||
"Background cycle failed; details suppressed"
|
||||
)
|
||||
processed = False
|
||||
self.stop_event.wait(1 if processed else 5)
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=45)
|
||||
|
||||
def healthy(self):
|
||||
return bool(
|
||||
self.thread and self.thread.is_alive() and not self.stop_event.is_set()
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def secret(name: str) -> str:
|
||||
path = os.getenv(name + "_FILE")
|
||||
return Path(path).read_text().strip() if path else os.getenv(name, "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
data_dir: Path = field(default_factory=lambda: Path(os.getenv("DATA_DIR", "/data")))
|
||||
config_dir: Path = field(
|
||||
default_factory=lambda: Path(os.getenv("CONFIG_DIR", "/config"))
|
||||
)
|
||||
mock: bool = field(
|
||||
default_factory=lambda: os.getenv("KOKONNA_MOCK", "true").lower() == "true"
|
||||
)
|
||||
write_enabled: bool = field(
|
||||
default_factory=lambda: (
|
||||
os.getenv("KOKONNA_WRITE_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
)
|
||||
api_key: str = field(default_factory=lambda: secret("KOKONNA_API_KEY"), repr=False)
|
||||
api_token: str = field(
|
||||
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
|
||||
)
|
||||
additional_secrets: list[str] = field(default_factory=list, repr=False)
|
||||
device_id: str = "living-room"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
cors_origins: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
x.strip()
|
||||
for x in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
|
||||
if x.strip()
|
||||
]
|
||||
)
|
||||
image_url_hosts: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
x.strip().lower()
|
||||
for x in os.getenv("IMAGE_URL_ALLOWED_HOSTS", "").split(",")
|
||||
if x.strip()
|
||||
]
|
||||
)
|
||||
font_path: str = field(
|
||||
default_factory=lambda: os.getenv(
|
||||
"FONT_PATH", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
||||
)
|
||||
)
|
||||
max_bytes: int = 20 * 1024 * 1024
|
||||
idempotency_hours: int = 168
|
||||
sync_timeout: int = 600
|
||||
|
||||
def sanitize(self, value):
|
||||
import json
|
||||
|
||||
encoded = json.dumps(value, ensure_ascii=False)
|
||||
for key in (self.api_key, self.api_token, *self.additional_secrets):
|
||||
if key:
|
||||
encoded = encoded.replace(key, "[REDACTED]")
|
||||
return json.loads(encoded)
|
||||
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def encode(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path):
|
||||
self.lock = threading.RLock()
|
||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.executescript("""
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA busy_timeout=5000;
|
||||
PRAGMA synchronous=FULL;
|
||||
CREATE TABLE IF NOT EXISTS playlists (id TEXT PRIMARY KEY, payload TEXT, position INTEGER, next_run TEXT);
|
||||
CREATE TABLE IF NOT EXISTS operations (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL, action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL, device_id TEXT NOT NULL, client_id TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
result TEXT, error TEXT, write_started INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
client_id TEXT, key TEXT, fingerprint TEXT NOT NULL, operation_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, PRIMARY KEY(client_id,key));
|
||||
CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, payload TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL, next_run TEXT, created_at TEXT, updated_at TEXT,
|
||||
last_run TEXT, last_result TEXT);
|
||||
CREATE TABLE IF NOT EXISTS job_runs (job_id TEXT, scheduled_at TEXT,
|
||||
operation_id TEXT, PRIMARY KEY(job_id,scheduled_at));
|
||||
CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, client_id TEXT,
|
||||
request_id TEXT, operation_id TEXT, device_id TEXT, action TEXT,
|
||||
result TEXT, timestamp TEXT);
|
||||
CREATE TABLE IF NOT EXISTS device_state (device_id TEXT PRIMARY KEY,
|
||||
previous_image_id INTEGER, base_image_id INTEGER, managed_image_id INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS overrides (id TEXT PRIMARY KEY, device_id TEXT,
|
||||
image_id INTEGER, priority INTEGER, expires_at TEXT, created_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS template_config (template_id TEXT PRIMARY KEY, payload TEXT);
|
||||
CREATE TABLE IF NOT EXISTS assets (id TEXT PRIMARY KEY, name TEXT, kind TEXT, created_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS clients (client_id TEXT PRIMARY KEY, token_hash TEXT,
|
||||
name TEXT, permissions TEXT, created_at TEXT, last_used_at TEXT, enabled INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, event_type TEXT,
|
||||
payload TEXT, created_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS webhook_subscriptions (id TEXT PRIMARY KEY,
|
||||
client_id TEXT, callback_url TEXT, event_types TEXT, enabled INTEGER,
|
||||
created_at TEXT);
|
||||
""")
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
with self.lock:
|
||||
self.conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
yield self.conn
|
||||
self.conn.commit()
|
||||
except Exception:
|
||||
self.conn.rollback()
|
||||
raise
|
||||
|
||||
def execute(self, query, args=()):
|
||||
with self.lock, self.conn:
|
||||
return self.conn.execute(query, args)
|
||||
|
||||
def one(self, query, args=()):
|
||||
with self.lock:
|
||||
row = self.conn.execute(query, args).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def all(self, query, args=()):
|
||||
with self.lock:
|
||||
return [dict(r) for r in self.conn.execute(query, args).fetchall()]
|
||||
|
||||
def history(self, op, result):
|
||||
self.execute(
|
||||
"INSERT INTO history(client_id,request_id,operation_id,device_id,action,result,timestamp) VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
op["client_id"],
|
||||
op["request_id"],
|
||||
op.get("id"),
|
||||
op["device_id"],
|
||||
op["action"],
|
||||
result,
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
|
||||
def event(self, event_type, payload):
|
||||
self.execute(
|
||||
"INSERT INTO events VALUES(?,?,?,?)",
|
||||
(uuid4().hex, event_type, encode(payload), now_iso()),
|
||||
)
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"id": "calendar",
|
||||
"name": "Calendar / 日历",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"date",
|
||||
"events",
|
||||
"photo_id",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
],
|
||||
"defaults": {
|
||||
"title": "日历 / Calendar",
|
||||
"events": [
|
||||
"暂无日程 / No events"
|
||||
]
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"field": "title",
|
||||
"box": [
|
||||
72,
|
||||
60,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "",
|
||||
"box": [
|
||||
72,
|
||||
235,
|
||||
1056,
|
||||
7
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "rule"
|
||||
},
|
||||
{
|
||||
"field": "updated",
|
||||
"box": [
|
||||
72,
|
||||
1500,
|
||||
1056,
|
||||
70
|
||||
],
|
||||
"size": 32,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "date",
|
||||
"box": [
|
||||
72,
|
||||
300,
|
||||
1056,
|
||||
100
|
||||
],
|
||||
"size": 66,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "weekday",
|
||||
"box": [
|
||||
72,
|
||||
430,
|
||||
1056,
|
||||
100
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "events",
|
||||
"box": [
|
||||
72,
|
||||
600,
|
||||
1056,
|
||||
800
|
||||
],
|
||||
"size": 54,
|
||||
"kind": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"id": "daily-dashboard",
|
||||
"name": "Daily Dashboard / 家庭早报",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"date",
|
||||
"events",
|
||||
"photo_id",
|
||||
"quote",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
],
|
||||
"defaults": {
|
||||
"title": "Good Morning / 早上好",
|
||||
"events": [
|
||||
"一起吃早餐 / Breakfast together",
|
||||
"给植物浇水 / Water the plants"
|
||||
],
|
||||
"quote": "把平凡的日子过成喜欢的样子。"
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"field": "title",
|
||||
"box": [
|
||||
72,
|
||||
60,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "",
|
||||
"box": [
|
||||
72,
|
||||
235,
|
||||
1056,
|
||||
7
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "rule"
|
||||
},
|
||||
{
|
||||
"field": "updated",
|
||||
"box": [
|
||||
72,
|
||||
1500,
|
||||
1056,
|
||||
70
|
||||
],
|
||||
"size": 32,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "date",
|
||||
"box": [
|
||||
72,
|
||||
275,
|
||||
1056,
|
||||
80
|
||||
],
|
||||
"size": 56,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "weekday",
|
||||
"box": [
|
||||
72,
|
||||
365,
|
||||
1056,
|
||||
65
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "weather",
|
||||
"box": [
|
||||
72,
|
||||
465,
|
||||
1056,
|
||||
180
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "events",
|
||||
"box": [
|
||||
72,
|
||||
690,
|
||||
1056,
|
||||
260
|
||||
],
|
||||
"size": 46,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "quote",
|
||||
"box": [
|
||||
72,
|
||||
1020,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 46,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "photo_id",
|
||||
"box": [
|
||||
72,
|
||||
1210,
|
||||
1056,
|
||||
235
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "photo"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"id": "photo-text",
|
||||
"name": "Photo + Text / 照片与文字",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"date",
|
||||
"events",
|
||||
"message",
|
||||
"photo_id",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
],
|
||||
"defaults": {
|
||||
"title": "我们的时光 / Our moments",
|
||||
"message": "记录值得记住的日常。"
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"field": "title",
|
||||
"box": [
|
||||
72,
|
||||
60,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "",
|
||||
"box": [
|
||||
72,
|
||||
235,
|
||||
1056,
|
||||
7
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "rule"
|
||||
},
|
||||
{
|
||||
"field": "updated",
|
||||
"box": [
|
||||
72,
|
||||
1500,
|
||||
1056,
|
||||
70
|
||||
],
|
||||
"size": 32,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "photo_id",
|
||||
"box": [
|
||||
72,
|
||||
295,
|
||||
1056,
|
||||
920
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "photo"
|
||||
},
|
||||
{
|
||||
"field": "message",
|
||||
"box": [
|
||||
72,
|
||||
1280,
|
||||
1056,
|
||||
170
|
||||
],
|
||||
"size": 48,
|
||||
"kind": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"id": "quote",
|
||||
"name": "Quote / 一句话",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"author",
|
||||
"date",
|
||||
"events",
|
||||
"photo_id",
|
||||
"quote",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
],
|
||||
"defaults": {
|
||||
"title": "今日一句 / A thought",
|
||||
"quote": "慢下来,生活自有答案。",
|
||||
"author": "Frame Flow"
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"field": "title",
|
||||
"box": [
|
||||
72,
|
||||
60,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "",
|
||||
"box": [
|
||||
72,
|
||||
235,
|
||||
1056,
|
||||
7
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "rule"
|
||||
},
|
||||
{
|
||||
"field": "updated",
|
||||
"box": [
|
||||
72,
|
||||
1500,
|
||||
1056,
|
||||
70
|
||||
],
|
||||
"size": 32,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "quote",
|
||||
"box": [
|
||||
72,
|
||||
460,
|
||||
1056,
|
||||
620
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "author",
|
||||
"box": [
|
||||
72,
|
||||
1200,
|
||||
1056,
|
||||
100
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"id": "system-alert",
|
||||
"name": "System Alert / 系统告警",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"date",
|
||||
"events",
|
||||
"message",
|
||||
"photo_id",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
],
|
||||
"defaults": {
|
||||
"title": "SYSTEM ALERT / 系统告警",
|
||||
"message": "需要检查设备状态 / Please check the device"
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"field": "title",
|
||||
"box": [
|
||||
72,
|
||||
60,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "",
|
||||
"box": [
|
||||
72,
|
||||
235,
|
||||
1056,
|
||||
7
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "rule"
|
||||
},
|
||||
{
|
||||
"field": "updated",
|
||||
"box": [
|
||||
72,
|
||||
1500,
|
||||
1056,
|
||||
70
|
||||
],
|
||||
"size": 32,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "message",
|
||||
"box": [
|
||||
72,
|
||||
430,
|
||||
1056,
|
||||
650
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "details",
|
||||
"box": [
|
||||
72,
|
||||
1150,
|
||||
1056,
|
||||
260
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"id": "weather",
|
||||
"name": "Weather / 天气",
|
||||
"width": 1200,
|
||||
"height": 1600,
|
||||
"fields": [
|
||||
"date",
|
||||
"events",
|
||||
"photo_id",
|
||||
"title",
|
||||
"weather",
|
||||
"weekday"
|
||||
],
|
||||
"defaults": {
|
||||
"title": "天气 / Weather"
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"field": "title",
|
||||
"box": [
|
||||
72,
|
||||
60,
|
||||
1056,
|
||||
150
|
||||
],
|
||||
"size": 72,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "",
|
||||
"box": [
|
||||
72,
|
||||
235,
|
||||
1056,
|
||||
7
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "rule"
|
||||
},
|
||||
{
|
||||
"field": "updated",
|
||||
"box": [
|
||||
72,
|
||||
1500,
|
||||
1056,
|
||||
70
|
||||
],
|
||||
"size": 32,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "date",
|
||||
"box": [
|
||||
72,
|
||||
285,
|
||||
1056,
|
||||
90
|
||||
],
|
||||
"size": 56,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "weather",
|
||||
"box": [
|
||||
72,
|
||||
440,
|
||||
1056,
|
||||
450
|
||||
],
|
||||
"size": 62,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"field": "forecast",
|
||||
"box": [
|
||||
72,
|
||||
990,
|
||||
1056,
|
||||
400
|
||||
],
|
||||
"size": 44,
|
||||
"kind": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
const $=s=>document.querySelector(s);
|
||||
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
|
||||
const icons={dashboard:'◫',gallery:'▧',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
|
||||
const t=k=>window.MESSAGES[state.lang][k]||k;
|
||||
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
|
||||
const buttons=(id,text,primary=false)=>`<button id="${id}" class="${primary?'primary':''}">${t(text)}</button>`;
|
||||
let toastTimer;
|
||||
function toast(message){$('#toast').textContent=message;$('#toast').style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').style.display='none',6000);}
|
||||
async function api(path,options={}){
|
||||
const headers={'Authorization':'Bearer '+state.token,...options.headers};
|
||||
if(options.body&&!(options.body instanceof FormData)){headers['Content-Type']='application/json';options.body=JSON.stringify(options.body);}
|
||||
const response=await fetch(path,{...options,headers});
|
||||
if(!response.ok){let error;try{error=(await response.json()).error;}catch{}
|
||||
const e=new Error((error?.code||response.status)+': '+(error?.code&&t(error.code)!==error.code?t(error.code):(error?.message||t('error'))));e.code=error?.code;throw e;}
|
||||
if(response.status===204)return null;
|
||||
return options.binary?await response.blob():await response.json();
|
||||
}
|
||||
function bind(id,fn){const element=$('#'+id);if(element)element.onclick=async event=>{try{element.disabled=true;await fn(event);}catch(e){toast(e.message);}finally{element.disabled=false;}};}
|
||||
function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.message);}};}
|
||||
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
|
||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}"><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#connection').textContent=state.settings?(state.settings.mock?t('mock'):(!state.settings.write_enabled?t('readOnly'):t('writable'))):t('noToken');$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(d.name)}</option>`).join('');}
|
||||
function clearBlobs(){for(const url of state.blobs)URL.revokeObjectURL(url);state.blobs=[];}
|
||||
async function blobImage(img,path){try{const blob=await api(path,{binary:true});if(!img.isConnected)return;const url=URL.createObjectURL(blob);state.blobs.push(url);img.src=url;}catch{img.alt=t('apiError');}}
|
||||
function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>observer.observe(img));}
|
||||
function notice(){if(!state.settings)return '';return `<div class="notice ${!state.settings.mock&&!state.settings.write_enabled?'warning':''}">${state.settings.mock?t('mock'):(!state.settings.write_enabled?t('readOnlyWarning'):t('writable'))}</div>`;}
|
||||
async function navigate(page=state.page){state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();clearBlobs();state.epoch++;const epoch=state.epoch;
|
||||
if(!state.token){login();return;}
|
||||
$('#main').innerHTML='<div class="spinner"></div>';
|
||||
try{await views[state.page](epoch);}catch(e){if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
||||
}
|
||||
function login(){ $('#main').innerHTML=`<div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="api-token">${t('token')}</label><input id="api-token" type="password" required autocomplete="off"></div><button class="primary" type="submit">${t('connect')}</button></form></div>`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();state.token=$('#api-token').value.trim();try{state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;await navigate();}catch(error){state.token='';toast(error.message);}});}
|
||||
async function accepted(path,payload){const key='web-'+crypto.randomUUID();const result=await api(path,{method:'POST',body:payload,headers:{'Idempotency-Key':key}});toast(t('accepted'));return result.operation_id;}
|
||||
async function waitOperation(id){for(let n=0;n<120;n++){const operation=await api('/api/v1/operations/'+id);if(operation.status==='COMPLETED')return operation;if(['FAILED','UNKNOWN'].includes(operation.status))throw new Error(operation.status==='UNKNOWN'?t('unknown'):(operation.error?.message||t('error')));await new Promise(resolve=>setTimeout(resolve,2000));}throw new Error(t('syncing'));}
|
||||
function showModal(html){$('#modal-content').innerHTML=html;$('#modal').showModal();bind('modal-cancel',()=>$('#modal').close());}
|
||||
async function confirmDialog(message,fn){showModal(`<h2>${t('confirm')}</h2><p>${esc(message)}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('modal-confirm','confirm',true)}</div>`);bind('modal-confirm',async()=>{await fn();$('#modal').close();});}
|
||||
function detail(label,value){return `<div><small>${t(label)}</small><strong>${esc(value)}</strong></div>`;}
|
||||
const views={
|
||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets');if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small>${x.current?`<div><span class="badge">${t('current')}</span></div>`:''}<div class="actions"><button class="primary" data-show="${i}">${t('display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{await accepted('/api/v1/display',{device_id:state.device,content:{type:'image',image_name:$('#image-name').value}});$('#modal').close();});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return accepted('/api/v1/display',{device_id:state.device,content:state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id}});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+crypto.randomUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
|
||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=false;});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',()=>{toast(t('done'));state.tab='local';return navigate('gallery');});bind('display-upload',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.preview.asset_id}}));},
|
||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}</div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${['☀','▦','◒','▣','❝','☁'][i]}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('previewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=false;});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>accepted('/api/v1/display',{device_id:state.device,content:{type:'image',asset_id:state.templatePreview}}));},
|
||||
async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',()=>{state.token='';state.settings=null;shell();login();});}
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,...changes};}
|
||||
function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});}
|
||||
function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('mode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});}
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||
applyTheme();navigate();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><span class="live-dot"></span><span id="connection">—</span><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/app.js"></script></body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user