Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation

This commit is contained in:
Pigway
2026-10-02 00:03:48 +08:00
commit 6652590b84
79 changed files with 13773 additions and 0 deletions
View File
View File
+749
View File
@@ -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
View File
+64
View File
@@ -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
+56
View File
@@ -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]
+510
View File
@@ -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,
)
+22
View File
@@ -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: ...
+144
View File
@@ -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
)
+104
View File
@@ -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)
+108
View File
@@ -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"])
+26
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from pigway_frame_flow.api.app import create_app
app = create_app()
+347
View File
@@ -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
+19
View File
@@ -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 []
+162
View File
@@ -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
+132
View File
@@ -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()
+273
View File
@@ -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()
)
+62
View File
@@ -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)
+103
View File
@@ -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()
+88
View File
@@ -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"
}
]
}
+78
View File
@@ -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"
}
]
}
+85
View File
@@ -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"
}
]
}
+54
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
+3
View File
@@ -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
+202
View File
@@ -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