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
+12
View File
@@ -0,0 +1,12 @@
.git
.env
.env.*
.venv
__pycache__
.pytest_cache
.ruff_cache
data
config
*.egg-info
build
dist
+16
View File
@@ -0,0 +1,16 @@
TZ=Asia/Shanghai
# Default local access. For NAS LAN access set this to the NAS LAN IP (recommended)
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
WEB_BIND=127.0.0.1
WEB_PORT=18787
KOKONNA_MOCK=true
KOKONNA_WRITE_ENABLED=false
KOKONNA_API_KEY=
FRAME_FLOW_TOKEN=
# Optional secret files mounted read-only through your own Compose override:
# KOKONNA_API_KEY_FILE=/run/secrets/kokonna_api_key
# FRAME_FLOW_TOKEN_FILE=/run/secrets/frame_flow_token
CORS_ALLOWED_ORIGINS=
IMAGE_URL_ALLOWED_HOSTS=
BUILD_COMMIT=development
BUILD_TIME=unknown
+1
View File
@@ -0,0 +1 @@
vendor/guooscar_kokonna.py -whitespace
+37
View File
@@ -0,0 +1,37 @@
name: Verify Frame Flow
on: [push, pull_request, workflow_dispatch]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.14'
- run: python -m pip install -e '.[test]'
- run: python -m ruff check pigway_frame_flow tests scripts
- run: python -m ruff format --check pigway_frame_flow tests scripts
- run: python -m mypy pigway_frame_flow
- run: python -m pytest -q
- run: python -m pip_audit -r requirements.lock --no-deps --disable-pip
- run: |
cp .env.example .env
python - <<'PY'
from pathlib import Path
import secrets
p=Path('.env')
p.write_text(p.read_text().replace('FRAME_FLOW_TOKEN=\n', 'FRAME_FLOW_TOKEN='+secrets.token_urlsafe(32)+'\n'))
PY
docker compose config --quiet
docker compose build --pull
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
docker compose up -d
for attempt in $(seq 1 30); do curl -fsS http://127.0.0.1:18787/ready >/dev/null && break || sleep 2; done
python scripts/smoke-test.py
docker compose restart pigway-frame-flow
for attempt in $(seq 1 30); do curl -fsS http://127.0.0.1:18787/ready >/dev/null && break || sleep 2; done
python scripts/smoke-test.py
docker inspect pigway-frame-flow --format '{{.HostConfig.RestartPolicy.Name}} {{.State.Health.Status}}'
docker image inspect pigway-frame-flow:latest --format '{{.Size}} {{.Architecture}}'
docker compose down
+17
View File
@@ -0,0 +1,17 @@
.env
.env.*
!.env.example
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
data/
*.egg-info/
config/*
!config/.gitkeep
*.db*
*.log
build/
dist/
.mypy_cache/
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.14.5-slim-bookworm@sha256:ec58d916f9e24a6035cab2bdf07f6206c4cc092a16613c60597534711332d9d6
ARG BUILD_COMMIT=development
ARG BUILD_TIME=unknown
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
DATA_DIR=/data CONFIG_DIR=/config BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
WORKDIR /app
# CJK fonts intentionally retained for readable Chinese rendering.
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10333 frameflow && useradd --uid 10333 --gid 10333 --no-create-home frameflow \
&& mkdir -p /data/images /data/generated /data/cache /config \
&& chown -R 10333:10333 /data /config
COPY requirements.lock ./
RUN pip install --no-cache-dir -r requirements.lock
COPY pigway_frame_flow ./pigway_frame_flow
COPY vendor ./vendor
USER 10333:10333
EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/health',timeout=3)"
CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0", "--port", "18787", "--workers", "1", "--no-access-log"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Pigway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
# Notices
Pigway Frame Flow is an independent project. KoKonna is a supported device
integration and is not affiliated with this project unless otherwise stated.
KoKonna names and marks belong to their respective owners.
## Community protocol reference
`@guooscar/kokonna-frame` 1.2.0 was downloaded from the official ClawHub API on
2026-10-01. Version metadata declares **MIT-0**; the ZIP does not include a separate
LICENSE. `vendor/guooscar_kokonna.py` preserves the original snapshot for provenance;
it is not imported or executed. `vendor/guooscar_protocol.py` adapts its raw base64
upload payload and device call to an injected safe transport. No OpenClaw runtime
is installed or required. Original metadata is retained in `vendor/metadata.json`.
The Home Assistant integration `patrickchen914/kokonna` (MIT) and
`nixihz/kokonna-skill` (README declares MIT, no root license detected) were inspected
for endpoint coverage. Their source code was not incorporated into runtime.
## Third-party dependencies
FastAPI, Starlette, Uvicorn, Requests, croniter, PyYAML, Pydantic and their transitive
dependencies retain their respective upstream licenses. Pillow uses its upstream
HPND license. Debian Noto CJK fonts retain their font package license.
Development dependencies (pytest, Ruff, mypy, pip-audit, HTTPX2, Playwright) retain
their upstream licenses and are not shipped in the runtime image.
The project's MIT license applies to original project code, not a relicensing of
third-party code, fonts, or trademarks. Dependency versions are in requirements.lock.
Swagger UI 5.33.1 is vendored from the official npm swagger-ui-dist package (Apache-2.0).
Its SHA-512 tarball integrity and package metadata are retained in vendor/swagger-metadata.json;
license is pigway_frame_flow/web/swagger-LICENSE. API docs work without external CDN access.
+161
View File
@@ -0,0 +1,161 @@
# Pigway Frame Flow
Pigway Frame Flow is a lightweight self-hosted platform for programmable smart-frame automation.
独立运行的智能画框服务:中英文 Web UI、深浅色模式、设备驱动抽象、KoKonna 全部已公开接口、图片处理、模板、SQLite 调度与播放列表、临时覆盖恢复和 REST API。
KoKonna is a supported device integration and is not affiliated with this project unless otherwise stated.
## Quick Start / 快速开始
```sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.lock
.venv/bin/python scripts/run_local.py
```
Open http://127.0.0.1:18787. The local demo creates a private `.env` with a random
FRAME_FLOW_TOKEN. Read that token locally in an editor and enter it on the login
screen. Do not paste a KoKonna API Key into Web UI. Demo always forces mock mode
and disables real writes. It leaves data persistent in ./data and ./config.
打开页面后输入 `.env` 中的 FRAME_FLOW_TOKEN;Token 只留在当前页面内存,刷新后需重新输入。
## Architecture / 架构
```
providers → templates / renderers → generated image
↓
Web UI / external REST → DisplayService → FrameDeviceDriver → KokonnaDriver
Scheduler / Playlist ─────────────┘
SQLite: operations / history / jobs / overrides / assets
```
Package: pigway_frame_flow. Device composition is in core/registry.py;
vendor-specific code is in drivers/kokonna. One process, one worker, no Redis,
message broker, OpenClaw, Home Assistant or external application dependency.
## Configuration / 配置
Copy `.env.example` to `.env`, set a strong FRAME_FLOW_TOKEN, chmod 600 .env.
- KOKONNA_MOCK=true: independent persistent demo, no cloud calls.
- KOKONNA_WRITE_ENABLED=false: live write protection, mandatory before verification.
- KOKONNA_API_KEY: device key, never stored in DB or returned to Web UI.
- *_FILE: optional Docker Secret equivalent.
- WEB_BIND/WEB_PORT: Compose host bind and port. Internal app binds container network.
- CORS_ALLOWED_ORIGINS: exact comma-separated origins; default empty.
- IMAGE_URL_ALLOWED_HOSTS: exact public HTTPS hosts; default disabled. Redirects denied.
- TZ=Asia/Shanghai; DATA_DIR=/data; CONFIG_DIR=/config.
For multiple devices copy deploy/devices.yml.example into config/devices.yml,
reference a separate environment key per device. Jobs bind a device_id. Layout
currently targets 1200 × 1600; incompatible geometry is refused before writes.
## Docker
```sh
cp .env.example .env
# Configure a token; keep API key empty, mock true, writes false.
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
./scripts/build-verify.sh
docker compose up -d
python3 scripts/smoke-test.py
```
Image pigway-frame-flow:latest, service/container pigway-frame-flow, AMD64,
Python 3.14.5, pinned runtime dependencies, CJK fonts, non-root UID/GID 10333,
read-only root filesystem, writable /data and /config, no capabilities, no host
network, no Docker socket. Restart unless-stopped. JSON-file logs capped 10m × 3.
Healthcheck uses service /health, not cloud availability. No second log database.
## Synology NAS
Target `/volume1/docker/pigway-frame-flow/`. See [NAS deployment](docs/NAS.md).
Set WEB_BIND to the NAS LAN IP to restrict the published interface, or explicitly
0.0.0.0 when desired. Port 18787 must be free. No router/firewall/reverse proxy
rules are modified. Source and persistent volumes stay in this project directory.
No artificial logs directory is required. Compose creates its own bridge network.
## KoKonna Setup / 真实设备
First deploy Mock and verify persistence. Then set KOKONNA_MOCK=false and fill
KOKONNA_API_KEY through private environment/secret. Keep writes false. /ready checks
configuration/DB/worker/token without a cloud call. Query device and images metadata;
compare model, geometry, timezone, heartbeat, battery, imageId, coverId, sync.
If different, stop and report. **Do not enable writes or perform the single test
until the owner explicitly authorizes it.** Test design: [REAL_DEVICE_TEST.md](docs/REAL_DEVICE_TEST.md).
## Security
All protected /api/v1 capabilities use Frame Flow Bearer auth and scope checks.
SQLite stores token hashes. Device keys live only in env/secret and runtime memory.
Cloud original-image endpoint embeds a key upstream; backend proxies it with no
redirects and no access/transport logging. Key never enters browser URLs or DB.
Request validation and exceptions use sanitized standard errors. Strong tokens,
trusted LAN and no public exposure are the intended deployment model.
## API
OpenAPI 3.x at /openapi.json; Swagger at /docs; stable /api/v1. Display/render/upload
return 202 + operation_id. Poll /api/v1/operations/{id}; completion requires matching
image ID and cloud synced flag. Idempotency-Key retained seven days. X-Request-ID
traces history. UNKNOWN writes block later mutations; read-only reconciliation
and explicit admin acknowledgement release the block, never replay a write.
The older /api/display route is retained and deprecated (same v1 payload).
See [external integration contract](docs/integrations/TICKTAR.md).
## Scheduler / Playback
Cron (5 fields), daily, weekdays, weekends, date and temporary tasks; Shanghai time.
Use source {"type":"restore"} for explicit restore jobs. Unique job/slot and persistent
idempotency prevent duplicate dispatch. Overdue tasks >5 minutes are skipped rather
than unexpectedly overwriting artwork after downtime. State persists across restart.
Overrides use priority then recency; lower priorities wait without uploading.
If another app changes the frame, expired overrides do not overwrite its artwork.
Frame Flow playlists support ordered/random cloud image IDs and >=5-minute intervals;
only one enabled playlist per device. They pause for overrides/unknown writes.
Native cloud slideshow mode/interval are readable; membership/editing are unavailable
in the official API. Avoid having native and Frame Flow playback compete.
## Templates / 图片
Six JSON layout templates: daily-dashboard, weather, calendar, quote, photo-text,
system-alert. color and grayscale profiles. Photo data accepts photo_id = local asset
ID. Mock weather is clearly labeled; no unreliable lunar date is fabricated.
Contain preserves composition with margins; cover crops centrally; smart is an
edge-saliency heuristic. Always inspect preview; face-aware guarantees are not claimed.
Cloud uploads activate immediately, so gallery-only means **local staging**.
## Development / Testing
```sh
.venv/bin/python -m pip install -e '.[test]'
.venv/bin/python -m pytest -q
.venv/bin/python -m ruff check pigway_frame_flow tests scripts
.venv/bin/python -m ruff format --check pigway_frame_flow tests scripts
.venv/bin/python -m mypy pigway_frame_flow
.venv/bin/python -m pip_audit -r requirements.lock --no-deps --disable-pip
```
Tests mock every cloud request; no real-device writes. scripts/ui-smoke.cjs runs an
isolated headless browser and checks settings.mock before acting. CI builds AMD64,
validates Compose, starts a container, checks health/ready and restarts for persistence.
## Known Limitations / 已知边界
Cloud acknowledgements do not prove physical display pixels. Model fields may be
omitted and are explicitly marked configured. No native slideshow membership API,
no cloud upload-only API, no automatic retries after uncertain writes. Cache has no
automatic quota/GC in 0.1.0; monitor data volume. Rendering assets are local, limited
to 25M pixels/20 MiB. This release uses a single worker and is not multi-replica.
Gallery reads have no pagination because upstream documents none. Web UI hides
secrets and lazy-loads thumbnails; generating a first thumbnail fetches one original.
Multi-client provisioning UI/webhook delivery are not implemented. Docker build/NAS
status is recorded honestly in docs/ACCEPTANCE.md.
## Roadmap
Additional drivers; scoped token administration; signed webhooks; cache retention;
face-aware cropping; public weather provider; Google Calendar, Home Assistant, MQTT,
NAS monitoring and AI integrations through the same API. External integration is optional.
View File
+10
View File
@@ -0,0 +1,10 @@
devices:
living-room:
driver: kokonna
name: 客厅相框
api_key_env: KOKONNA_API_KEY
enabled: true
# bedroom:
# driver: kokonna
# api_key_env: KOKONNA_BEDROOM_API_KEY
# enabled: true
+43
View File
@@ -0,0 +1,43 @@
name: pigway-frame-flow
services:
pigway-frame-flow:
image: pigway-frame-flow:latest
container_name: pigway-frame-flow
platform: linux/amd64
build:
context: .
args:
BUILD_COMMIT: ${BUILD_COMMIT:-development}
BUILD_TIME: ${BUILD_TIME:-unknown}
restart: unless-stopped
init: true
user: "10333:10333"
ports:
- "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-18787}:18787"
env_file: .env
environment:
TZ: Asia/Shanghai
DATA_DIR: /data
CONFIG_DIR: /config
volumes:
- ./data:/data
- ./config:/config
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 100
mem_limit: 768m
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/health',timeout=3)"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+115
View File
@@ -0,0 +1,115 @@
# Synology NAS deployment / 群晖部署
Target: DS923+, DSM 7.3.1, AMD64, Container Manager.
Directory: /volume1/docker/pigway-frame-flow. Port: 18787.
## 1. Package and upload / 打包上传
From the repository:
```sh
python3 scripts/package-nas.py
scp -O dist/pigway-frame-flow-nas.tar.gz nas-example:/tmp/pigway-frame-flow-nas.tar.gz
ssh nas-example
```
The package contains tracked source/config examples/scripts only, never .env,
actual keys, data or .git. It is a deployment artifact, not a Git backup.
Use `git bundle create frame-flow.bundle --all` separately for history backups.
## 2. Prepare project directory / 准备目录
Verify target is empty or explicitly backed up before extracting. Do not extract
onto another existing installation or other services. If the directory exists,
inspect it first; preserve its .env and data.
```sh
mkdir -p /volume1/docker/pigway-frame-flow
# Only for an empty/new target:
tar -xzf /tmp/pigway-frame-flow-nas.tar.gz -C /volume1/docker
cd /volume1/docker/pigway-frame-flow
mkdir -p data/images data/generated data/cache config
sudo chown -R 10333:10333 data config
cp .env.example .env
chmod 600 .env
```
Edit `.env` on the NAS. Set FRAME_FLOW_TOKEN to a strong random token. API key
stays empty. KOKONNA_MOCK=true and KOKONNA_WRITE_ENABLED=false.
Set WEB_BIND to the actual NAS LAN address (recommended) or explicitly 0.0.0.0;
WEB_PORT=18787. The latter binds every NAS interface, so use the LAN IP when
strict LAN-only binding is required. No firewall/router/proxy edits are performed.
Copy deploy/devices.yml.example to config/devices.yml if multiple devices are desired.
## 3. Validate, build, start / 验证构建启动
Synology Docker is /usr/local/bin/docker. The SSH user may require sudo. Do not
change socket ownership, groups, Docker network settings or other containers.
```sh
sudo /usr/local/bin/docker compose config --quiet
sudo /usr/local/bin/docker compose build --pull
sudo /usr/local/bin/docker compose up -d
sudo /usr/local/bin/docker compose ps
sudo /usr/local/bin/docker compose logs --tail 100
sudo /usr/local/bin/docker inspect pigway-frame-flow \
--format 'id={{.Id}} started={{.State.StartedAt}} health={{.State.Health.Status}} restart={{.HostConfig.RestartPolicy.Name}} ports={{json .NetworkSettings.Ports}} mounts={{json .Mounts}} network={{json .NetworkSettings.Networks}}'
sudo /usr/local/bin/docker image inspect pigway-frame-flow:latest \
--format 'id={{.Id}} size={{.Size}} architecture={{.Architecture}}'
```
Do not print unfiltered `docker inspect`: its environment can contain keys/tokens.
Likewise do not print `docker compose config` without --quiet on a live installation.
Run the read-only smoke checker from a machine with Python and set FRAME_FLOW_URL
to http://NAS_LAN_IP:18787; FRAME_FLOW_TOKEN stays in private env/file.
Health: GET /health → 200. Readiness: GET /ready → 200 when SQLite, driver config,
auth and Scheduler are ready. Missing credentials in live mode → 503. The cloud
is not probed by readiness. Cloud loss does not cause healthcheck restart loops.
## 4. Verify persistence / 持久化
Create a disabled test schedule in Web UI and locally stage one image (Mock only).
Record the job and asset IDs, then:
```sh
sudo /usr/local/bin/docker compose restart pigway-frame-flow
```
Wait for healthy state, check /health and /ready, and verify the same job/asset
IDs and history remain. Database is data/frameflow.db with WAL/SHM alongside it;
back up while stopped or use SQLite backup API, not a lone live DB file copy.
Restart policy must read unless-stopped. Do not reboot the NAS for acceptance.
## 5. Live read-only verification / 实机只读
After Mock acceptance, fill KOKONNA_API_KEY; set mock=false, keep writes=false.
Recreate only this service to apply env. Query device and images metadata only.
Verify known model/geometry/timezone/name against real data; missing vendor model
fields are not claimed verified. No original downloads are necessary. Stop on
mismatch; never try to fix cloud state automatically. Obtain explicit owner
permission before any upload/switch/delete. See REAL_DEVICE_TEST.md.
## Layout
```
/volume1/docker/pigway-frame-flow/
docker-compose.yml
Dockerfile
.env
requirements.lock
pigway_frame_flow/
vendor/
scripts/
config/
data/
frameflow.db
images/
generated/
cache/
```
Container runs UID/GID 10333:10333; only data/config need ownership. Avoid recursive
ownership changes outside this project's paths. Standard Compose bridge is private
and isolated by project name. Logs use stdout/stderr with Docker's capped json-file.
No logs directory or Docker socket mount.
+27
View File
@@ -0,0 +1,27 @@
# Real device test plan / 单次实机测试计划
**Not executed. Explicit owner authorization is required before any write.**
只读核对完成后询问:“真实设备只读验收完成,是否允许进行一次真实图片上传与显示测试?”
1. GET Frame Flow device (driver POST /device); record current imageId AND coverId,
synced, geometry, model, rotation, timezone, firmware, heartbeat and battery.
2. POST /listImages for metadata only. Record count, current image, sorted IDs,
duplicates, name/date/dimensions anomalies. No download-all action.
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
5. Record an operation ID and previous image; one upload only, no write retries.
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
reconcile gallery/device; never upload again to guess the outcome.
7. Keep the test image visible at most 1–3 minutes, then restore the recorded artwork
with a separate tracked switch operation. Query once more to confirm restored
imageId and synced. Record coverId after restoration too.
8. Delete only that known test image after restoration is positively confirmed AND
it is not a base/previous/override/current reference. If ambiguous, leave it and
report. Never delete art or retry a timed-out delete.
9. Disable real writes again. Report each operation ID/result and remaining test image.
The API cannot set coverId. Restoration guarantees a requested image switch when
confirmed, not arbitrary cover metadata restoration. Physical screen refresh has
no documented pixel telemetry; visual owner observation supplements cloud synced.
+55
View File
@@ -0,0 +1,55 @@
# KoKonna API research / 接口调查
Checked 2026-10-01 against https://kokonna.art/pages/openapi.
Base: https://api.kokonna.art/openapi. No fallback to legacy galaxyguide.cn.
| Official endpoint | Frame Flow capability |
|---|---|
| POST /device | Device panel, standardized device model, charging, firmware, sync, cover, storage, native slideshow mode/interval |
| POST /upload | Rendered JPEG upload and immediate activation, asynchronous operation |
| POST /listImages | On-demand cloud metadata gallery, current marker, name/date/geometry/size |
| POST /deleteImage | Confirmed deletion with restore-reference protection |
| GET /image/:apikey/:imageId | Authenticated server proxy, original bytes, lazy cached thumbnails; key never sent to browser |
| POST /displayImageById | Switch through unified DisplayService |
| POST /displayImageByName | Explicit filename switch; ambiguous names rejected |
## Native slideshow / 原生轮播
`switchType` (queue/random) and `switchMinute` are readable through device.
There is no documented playlist-membership API and no slideshow-setting API.
`listImages` returns the entire cloud gallery sorted by descending ID; it does
not establish playlist membership. No official pagination parameter is documented.
Frame Flow labels membership as unavailable, native editing unsupported, and
provides its own SQLite-backed playlists as an independent capability.
## Cloud constraints / 云端边界
Official limit: 20 requests per minute per device key; local transport reserves
headroom at 18. Device state has a 60-second cache, gallery is loaded on demand.
Uploads automatically activate a new image; there is no upload-without-activation
flag. Gallery-only uploads are therefore local staging, not a fictitious cloud
operation. Maximum upstream request size is 50 MB; this service accepts 20 MiB
image files with bounded decoded pixels. No non-idempotent cloud write is retried.
Cloud acknowledgement is not physical refresh confirmation; /device synced and
imageId drive the operation state. Unconfirmed or interrupted writes become UNKNOWN
and block later device writes until read-only reconciliation/explicit acknowledgement.
## Reuse / 复用
ClawHub API: https://clawhub.ai/api/v1/skills/kokonna-frame
Versioned ZIP: https://clawhub.ai/api/v1/download?slug=kokonna-frame&version=1.2.0
Community package owner: guooscar; metadata license MIT-0. Reused protocol payload
and device request, with an injected safe transport. It is a skill/CLI, not a
proven NAS daemon, and its default host and screen field assumptions are dated.
We implement scheduling, storage, driver normalization, renderer, REST, auth,
operations, idempotency, recovery, bilingual UI and deployment independently.
Other inspected repositories:
- https://github.com/patrickchen914/kokonna : Home Assistant integration, MIT;
depends on Home Assistant and had legacy host/key-in-image-URL behavior.
- https://github.com/nixihz/kokonna-skill : broader CLI/Hermes endpoint coverage,
README MIT, legacy host and raw upstream error messages. Not incorporated.
- https://github.com/zhengweiran66-eng/kokonna-product.html : product page.
No real device key was used during development. Cloud host root returned 530 in
an unauthenticated research request; no claim of current real-device connectivity.
+101
View File
@@ -0,0 +1,101 @@
# Optional external integration contract
Ticktar integration is optional. Frame Flow has no runtime dependency on Ticktar.
No external client receives a KoKonna API key, accesses the SQLite database, or
imports a vendor SDK. Web UI and external clients use the same application services.
## Architecture
External client → /api/v1 → Application / DisplayService → FrameDeviceDriver → KoKonna.
Configure the caller with FRAME_FLOW_URL and FRAME_FLOW_TOKEN only. No caller-specific
branch exists in core. Frame Flow starts and works with no integration configuration.
## Authentication and permissions
Authorization: Bearer FRAME_FLOW_TOKEN. The bootstrap token is provided through
.env or a secret file; database stores SHA-256 hash only. The client model supports
client_id, name, permissions, enabled, created_at, last_used_at. Permissions:
devices:read, images:read, images:delete, display:write, templates:read, render:write,
jobs:read, jobs:write, history:read, settings:write, admin.
0.1.0 seeds one admin token; provisioning scoped client rows is supported by middleware
but multi-token management UI is not implemented. Use a trusted server-to-server
connection; CORS is opt-in by exact origins, never wildcard by default.
## Discovery and capabilities
GET /api/v1/devices; GET /api/v1/devices/{id}; GET /api/v1/templates.
Device provides id/name/status, display geometry, power, current_content, firmware,
model/model_code, timezone, last_seen_at, capabilities. Model fallback is flagged
model_verified=false when the cloud omits model fields. Do not branch on driver:
check capabilities upload/gallery/delete/switch/battery/color/remote_refresh.
Native slideshow members/editing are unavailable in KoKonna's documented API.
## Display
POST /api/v1/display, Idempotency-Key: a-unique-client-request-key
```json
{
"device_id": "living-room",
"content": {
"type": "template",
"template": "daily-dashboard",
"data": {"title": "Good Morning", "events": []},
"profile": "color"
},
"options": {"duration": 1800, "restore_previous": true, "priority": 50},
"source": {"client": "external-app", "request_id": "caller-trace"}
}
```
Content supports image (exactly one image_id/image_name/asset_id/base64), image_url
(HTTPS/public DNS/exact allowlist), template. html/markdown explicitly return
UNSUPPORTED_CAPABILITY. For upload-only use device /upload with display_now=false;
this is safe local staging because KoKonna cloud upload always activates an image.
Returns HTTP 202: {operation_id, job_id, status:"accepted"}. job_id is a legacy
acceptance alias for operation_id, not a scheduler job ID.
## Render and operations
POST /api/v1/render with template content returns 202 and a local rendered asset
when completed; no device write. GET /api/v1/operations/{operation_id}:
QUEUED → RENDERING → UPLOADING → SYNCING → COMPLETED / FAILED / UNKNOWN.
Result includes image_id or asset_id/url. SYNCING confirms imageId plus synced
through read-only device status; there is no documented physical-screen telemetry.
Token must own the operation or be admin. Never interpret HTTP 202 as refreshed.
## Idempotency
Key is scoped to authenticated client, retained seven days. Same key and same
normalized action/body returns the same operation, even across restart. A different
body with the same key returns CONFLICT (409). Do not retry UNKNOWN with a new key.
Cloud writes have no automatic retries. UNKNOWN blocks further device writes;
admin /operations/{id}/reconcile reads device/gallery and may acknowledge a manually
verified outcome without reissuing a write.
## Errors and tracing
All API errors: {error:{code,message,request_id,details:{}}}. Stable codes include
INVALID_REQUEST, UNAUTHORIZED, FORBIDDEN, DEVICE_NOT_FOUND, DEVICE_OFFLINE,
UNSUPPORTED_CAPABILITY, RENDER_FAILED, UPLOAD_FAILED, WRITE_RESULT_UNKNOWN,
RATE_LIMITED, INTERNAL_ERROR, CONFIG_REQUIRED, WRITE_DISABLED, CONFLICT, NOT_FOUND,
CLOUD_UNAVAILABLE. Use code, not message text. Validation does not echo inputs.
X-Request-ID accepts a bounded safe identifier; it is returned on the response.
The authenticated client is authoritative; source.client is optional metadata.
History stores client_id/request_id/operation_id/device_id/action/result/timestamp.
No internal endpoints, headers, stacks, paths or secrets are returned.
## Future webhook
SQLite events and webhook_subscriptions reserve operation.completed/failed,
display.changed, device.online/offline/low_battery events. 0.1.0 emits operation/display
events only; no callback delivery, retry worker or registration API is implemented.
Future deliveries should use signed payloads and bounded retries.
OAuth, tenancy, RBAC management and generated SDKs are future work.
## Compatibility
/openapi.json and /docs are the contract. Snapshot tests cover paths, response
codes, standard device fields, security and operation enums. Static snapshot is
also supplied in docs/openapi.json. Breaking changes require /api/v2.
+6766
View File
File diff suppressed because it is too large Load Diff
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
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "pigway-frame-flow"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies]
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
[tool.ruff]
exclude = ["vendor", ".venv"]
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["pigway_frame_flow*", "vendor*"]
[tool.setuptools.package-data]
pigway_frame_flow = ["templates/*.json", "web/*"]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "B"]
ignore = ["B008"] # FastAPI dependency defaults are declarative framework markers.
[tool.mypy]
python_version = "3.14"
check_untyped_defs = true
ignore_missing_imports = true
exclude = ["vendor/", "tests/"]
+24
View File
@@ -0,0 +1,24 @@
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
certifi==2026.7.22
charset-normalizer==3.5.2
click==8.5.0
croniter==6.2.4
fastapi==0.142.2
h11==0.16.0
idna==3.20
opentelemetry-api==1.45.0
pillow==12.3.0
pydantic==2.13.5
pydantic-core==2.46.5
python-dateutil==2.9.0.post0
python-multipart==0.0.32
pyyaml==6.0.3
requests==2.34.2
six==1.17.0
starlette==1.7.0
typing-extensions==4.16.0
typing-inspection==0.4.4
urllib3==2.8.0
uvicorn==0.54.0
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
DOCKER_BIN=${DOCKER_BIN:-docker}
"$DOCKER_BIN" info >/dev/null
"$DOCKER_BIN" compose config --quiet
"$DOCKER_BIN" compose build --pull
"$DOCKER_BIN" image inspect pigway-frame-flow:latest --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
+40
View File
@@ -0,0 +1,40 @@
"""Validate Compose on a named SSH host, in a fresh temporary directory.
No Docker daemon access, project deployment or existing container mutation.
"""
import os
import shlex
import subprocess
from pathlib import Path
root = Path(__file__).resolve().parents[1]
host = os.getenv("FRAME_FLOW_SSH_HOST", "nas-example")
args = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host]
remote = subprocess.check_output(
args + ["mktemp -d /tmp/pigway-frame-flow-compose.XXXXXX"], text=True
).strip()
if not remote.startswith("/tmp/pigway-frame-flow-compose.") or any(
c in remote for c in " \n\r"
):
raise RuntimeError("Invalid temporary path")
subprocess.run(
[
"scp",
"-O",
str(root / "docker-compose.yml"),
host + ":" + remote + "/docker-compose.yml",
],
check=True,
)
subprocess.run(
["scp", "-O", str(root / ".env.example"), host + ":" + remote + "/.env"], check=True
)
subprocess.run(
args
+ [
"cd " + shlex.quote(remote) + " && /usr/local/bin/docker compose config --quiet"
],
check=True,
)
print("docker compose config: PASS (remote CLI only; not a Docker build)")
+18
View File
@@ -0,0 +1,18 @@
"""Package only tracked project files; never include .env, data, .git or caches."""
import subprocess
import tarfile
from pathlib import Path
root = Path(__file__).resolve().parents[1]
output = root / "dist/pigway-frame-flow-nas.tar.gz"
output.parent.mkdir(exist_ok=True)
files = (
subprocess.check_output(["git", "ls-files", "-z"], cwd=root).decode().split("\0")
)
with tarfile.open(output, "w:gz") as archive:
for file in files:
if not file or file == ".env" or file.startswith(("data/", ".git/")):
continue
archive.add(root / file, arcname="pigway-frame-flow/" + file)
print(output)
+79
View File
@@ -0,0 +1,79 @@
"""Record/check persistent Mock IDs across a process/container restart."""
import argparse
import json
import os
import time
from pathlib import Path
from urllib.request import Request, urlopen
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=["record", "check"])
args = parser.parse_args()
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
token = os.getenv("FRAME_FLOW_TOKEN", "")
if not token:
for line in Path(".env").read_text().splitlines():
if line.startswith("FRAME_FLOW_TOKEN="):
token = line.split("=", 1)[1]
def api(path, method="GET", payload=None):
request = Request(
base + path,
method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
},
)
with urlopen(request, timeout=10) as response:
return json.loads(response.read())
assert api("/api/v1/settings")["mock"] is True, "Persistence test requires Mock mode"
record = Path("data/persistence-acceptance.json")
if args.mode == "record":
job = api(
"/api/v1/jobs",
"POST",
{
"name": "Persistence acceptance (paused)",
"enabled": False,
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "template", "template": "quote"},
},
)
accepted = api(
"/api/v1/render",
"POST",
{
"type": "template",
"template": "quote",
"data": {"title": "PERSISTENCE TEST"},
},
)
for _ in range(20):
op = api("/api/v1/operations/" + accepted["operation_id"])
if op["status"] == "COMPLETED":
break
time.sleep(1)
assert op["status"] == "COMPLETED"
record.write_text(
json.dumps(
{
"job_id": job["id"],
"operation_id": op["operation_id"],
"asset_id": op["result"]["asset_id"],
}
)
)
print("Persistent Mock job, operation and image recorded")
else:
saved = json.loads(record.read_text())
assert any(j["id"] == saved["job_id"] for j in api("/api/v1/jobs"))
assert api("/api/v1/operations/" + saved["operation_id"])["status"] == "COMPLETED"
assert any(a["asset_id"] == saved["asset_id"] for a in api("/api/v1/assets"))
assert api("/ready")["status"] == "ready"
print("Restart persistence: 4 checks passed (job/operation/asset/readiness)")
+44
View File
@@ -0,0 +1,44 @@
"""Start a loopback-only demo. Generate a private .env if absent; no cloud writes."""
import os
import secrets
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
import uvicorn
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root))
env = root / ".env"
if not env.exists():
text = (
(root / ".env.example")
.read_text()
.replace(
"FRAME_FLOW_TOKEN=\n",
"FRAME_FLOW_TOKEN=" + secrets.token_urlsafe(32) + "\n",
)
)
env.write_text(text)
env.chmod(0o600)
for line in env.read_text().splitlines():
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
os.environ.setdefault(key, value)
os.environ["DATA_DIR"] = str(root / "data")
os.environ["CONFIG_DIR"] = str(root / "config")
os.environ["KOKONNA_MOCK"] = "true"
os.environ["KOKONNA_WRITE_ENABLED"] = "false"
os.environ["BUILD_COMMIT"] = (
subprocess.run(
["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True
).stdout.strip()
or "development"
)
os.environ["BUILD_TIME"] = datetime.now(UTC).isoformat()
uvicorn.run(
"pigway_frame_flow.main:app", host="127.0.0.1", port=18787, access_log=False
)
+29
View File
@@ -0,0 +1,29 @@
"""Read-only HTTP smoke checks. No frame writes; token read from env/private .env."""
import os
from pathlib import Path
from urllib.request import Request, urlopen
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
token = os.getenv("FRAME_FLOW_TOKEN", "")
if not token and Path(".env").exists():
for line in Path(".env").read_text().splitlines():
if line.startswith("FRAME_FLOW_TOKEN="):
token = line.split("=", 1)[1]
for path in [
"/health",
"/ready",
"/api/v1/version",
"/openapi.json",
"/docs",
"/",
"/api/v1/devices",
"/api/v1/templates",
"/api/v1/jobs",
]:
request = Request(base + path, headers={"Authorization": "Bearer " + token})
with urlopen(request, timeout=15) as response:
body = response.read()
print(path, "HTTP", response.status, "bytes", len(body))
assert response.status == 200
print("Read-only smoke checks passed")
+29
View File
@@ -0,0 +1,29 @@
/* Headless test of this app only; never attaches to the user's browser session. */
const fs=require('fs');const path=require('path');const assert=require('assert');const {chromium}=require('playwright');
(async()=>{
const root=path.resolve(__dirname,'..');const env=fs.readFileSync(path.join(root,'.env'),'utf8');const token=env.split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice('FRAME_FLOW_TOKEN='.length);
const base='http://127.0.0.1:18787';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode');
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message.replaceAll(token,'[REDACTED]')));let checks=0;
await page.goto(base);await page.locator('#api-token').fill(token);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++;
fs.mkdirSync(path.join(root,'docs/screenshots'),{recursive:true});await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-light-zh.png'),fullPage:true});
await page.locator('#lang').click();await page.getByRole('heading',{name:'Dashboard',exact:true}).waitFor();checks++;
await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++;
await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-dark-en.png'),fullPage:true});
await page.locator('[data-page="gallery"]').click();await page.locator('.image-card').first().waitFor();await page.locator('.image-card .thumb').first().evaluate(img=>new Promise((resolve,reject)=>{if(img.complete&&img.naturalWidth)return resolve();img.onload=resolve;img.onerror=reject;}));checks++;
await page.locator('#show-name').click();await page.locator('#image-name').fill('Original Art.jpg');await page.locator('#modal-cancel').click();checks++;
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===6);checks++;
await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++;
await page.locator('#display-template').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;
await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++;
await page.locator('[data-job-toggle]').first().click();await page.getByText('Paused',{exact:true}).first().waitFor();checks++;
await page.locator('[data-job-delete]').first().click();await page.locator('#modal-confirm').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor({state:'hidden'});checks++;
await page.locator('[data-page="playlists"]').click();await page.locator('#new-playlist').click();await page.locator('#playlist-name').fill('UI art');await page.locator('#playlist-images').fill('1');await page.locator('#playlist-form button[type=submit]').click();await page.getByText('UI art',{exact:true}).waitFor();checks++;
await page.locator('[data-pl-delete]').click();await page.locator('#modal-confirm').click();checks++;
await page.locator('[data-page="settings"]').click();await page.locator('#theme-select').waitFor();await page.locator('#theme-select').selectOption('light');assert(await page.locator('html').getAttribute('data-theme')==='light');checks++;
await page.locator('#language-select').selectOption('zh');await page.getByRole('heading',{name:'设置',exact:true}).waitFor();checks++;
await page.setViewportSize({width:390,height:844});await page.locator('[data-page="dashboard"]').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));checks++;
await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-mobile-zh.png'),fullPage:true});
assert(errors.length===0,JSON.stringify(errors));checks++;
await browser.close();console.log('Browser smoke:',checks,'passed; page errors:',errors.length);
})().catch(error=>{console.error(error.message);process.exit(1)});
+32
View File
@@ -0,0 +1,32 @@
import pytest
from fastapi.testclient import TestClient
from pigway_frame_flow.api.app import create_app
from pigway_frame_flow.settings import Settings
@pytest.fixture
def settings(tmp_path):
return Settings(
data_dir=tmp_path / "data",
config_dir=tmp_path / "config",
api_token="test-frame-token",
mock=True,
)
@pytest.fixture
def app(settings):
return create_app(settings, start_worker=False)
@pytest.fixture
def client(app):
with TestClient(app) as c:
c.headers["Authorization"] = "Bearer test-frame-token"
yield c
@pytest.fixture
def service(app):
return app.state.service
+536
View File
@@ -0,0 +1,536 @@
{
"/health": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/ready": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/version": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/images": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/images/{image_id}/file": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/display": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/display": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/display": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/render": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/upload": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/preview": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/assets": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/assets/{asset_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/images/{image_id}": {
"delete": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/devices/{device_id}/restore": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/operations": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/operations/{operation_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/operations/{operation_id}/reconcile": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/templates": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/templates/{template_id}": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/templates/{template_id}/config": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/jobs": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/jobs/{job_id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/playlists": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/playlists/{playlist_id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/history": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/settings": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
}
}
+751
View File
@@ -0,0 +1,751 @@
{
"Device": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"driver": {
"type": "string",
"const": "kokonna",
"title": "Driver",
"default": "kokonna"
},
"status": {
"type": "string",
"enum": [
"online",
"offline"
],
"title": "Status"
},
"model": {
"type": "string",
"title": "Model"
},
"model_code": {
"type": "string",
"title": "Model Code"
},
"firmware": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Firmware"
},
"display": {
"$ref": "#/components/schemas/DisplayGeometry"
},
"power": {
"$ref": "#/components/schemas/Power"
},
"current_content": {
"$ref": "#/components/schemas/CurrentContent"
},
"last_seen_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Last Seen At"
},
"timezone": {
"type": "string",
"title": "Timezone"
},
"capabilities": {
"$ref": "#/components/schemas/Capabilities"
},
"slideshow": {
"additionalProperties": true,
"type": "object",
"title": "Slideshow"
},
"storage_used_bytes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Storage Used Bytes"
},
"model_verified": {
"type": "boolean",
"title": "Model Verified",
"default": false
}
},
"type": "object",
"required": [
"id",
"name",
"status",
"model",
"model_code",
"firmware",
"display",
"power",
"current_content",
"last_seen_at",
"timezone"
],
"title": "Device"
},
"Capabilities": {
"properties": {
"upload": {
"type": "boolean",
"title": "Upload",
"default": true
},
"gallery": {
"type": "boolean",
"title": "Gallery",
"default": true
},
"delete": {
"type": "boolean",
"title": "Delete",
"default": true
},
"switch": {
"type": "boolean",
"title": "Switch",
"default": true
},
"remote_refresh": {
"type": "boolean",
"title": "Remote Refresh",
"default": true
},
"battery": {
"type": "boolean",
"title": "Battery",
"default": true
},
"color": {
"type": "boolean",
"title": "Color",
"default": true
},
"cloud_upload_without_display": {
"type": "boolean",
"title": "Cloud Upload Without Display",
"default": false
}
},
"type": "object",
"title": "Capabilities"
},
"DisplayGeometry": {
"properties": {
"width": {
"type": "integer",
"title": "Width"
},
"height": {
"type": "integer",
"title": "Height"
},
"rotation": {
"type": "integer",
"title": "Rotation"
},
"color_mode": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"type": "object",
"required": [
"width",
"height",
"rotation"
],
"title": "DisplayGeometry"
},
"Power": {
"properties": {
"battery_percent": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Battery Percent"
},
"charging": {
"type": "boolean",
"title": "Charging"
}
},
"type": "object",
"required": [
"battery_percent",
"charging"
],
"title": "Power"
},
"CurrentContent": {
"properties": {
"type": {
"type": "string",
"const": "image",
"title": "Type",
"default": "image"
},
"id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Id"
},
"cover_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Cover Id"
},
"synced": {
"type": "boolean",
"title": "Synced"
}
},
"type": "object",
"required": [
"id",
"cover_id",
"synced"
],
"title": "CurrentContent"
},
"GalleryImage": {
"properties": {
"width": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Width"
},
"height": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Height"
},
"size_bytes": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Size Bytes"
},
"media_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Media Type"
},
"image_id": {
"type": "integer",
"title": "Image Id"
},
"name": {
"type": "string",
"title": "Name"
},
"created_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created At"
},
"current": {
"type": "boolean",
"title": "Current"
},
"url": {
"type": "string",
"title": "Url"
}
},
"type": "object",
"required": [
"image_id",
"name",
"created_at",
"current",
"url"
],
"title": "GalleryImage"
},
"DisplayRequest": {
"properties": {
"device_id": {
"type": "string",
"title": "Device Id",
"default": "living-room"
},
"content": {
"oneOf": [
{
"$ref": "#/components/schemas/ImageContent"
},
{
"$ref": "#/components/schemas/URLContent"
},
{
"$ref": "#/components/schemas/TemplateContent"
},
{
"$ref": "#/components/schemas/FutureContent"
}
],
"title": "Content",
"discriminator": {
"propertyName": "type",
"mapping": {
"html": "#/components/schemas/FutureContent",
"image": "#/components/schemas/ImageContent",
"image_url": "#/components/schemas/URLContent",
"markdown": "#/components/schemas/FutureContent",
"template": "#/components/schemas/TemplateContent"
}
}
},
"options": {
"$ref": "#/components/schemas/Options"
},
"source": {
"$ref": "#/components/schemas/Source"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"content"
],
"title": "DisplayRequest"
},
"ImageContent": {
"properties": {
"type": {
"type": "string",
"const": "image",
"title": "Type"
},
"image_id": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0.0
},
{
"type": "null"
}
],
"title": "Image Id"
},
"image_name": {
"anyOf": [
{
"type": "string",
"maxLength": 120,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Image Name"
},
"asset_id": {
"anyOf": [
{
"type": "string",
"pattern": "^[a-f0-9]{32}$"
},
{
"type": "null"
}
],
"title": "Asset Id"
},
"base64": {
"anyOf": [
{
"type": "string",
"maxLength": 28000000
},
{
"type": "null"
}
],
"title": "Base64"
},
"name": {
"type": "string",
"maxLength": 120,
"minLength": 1,
"title": "Name",
"default": "image.jpg"
},
"fit": {
"$ref": "#/components/schemas/Fit",
"default": "contain"
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type"
],
"title": "ImageContent"
},
"URLContent": {
"properties": {
"type": {
"type": "string",
"const": "image_url",
"title": "Type"
},
"url": {
"type": "string",
"maxLength": 2048,
"title": "Url"
},
"name": {
"type": "string",
"maxLength": 120,
"title": "Name",
"default": "image.jpg"
},
"fit": {
"$ref": "#/components/schemas/Fit",
"default": "contain"
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type",
"url"
],
"title": "URLContent"
},
"TemplateContent": {
"properties": {
"type": {
"type": "string",
"const": "template",
"title": "Type"
},
"template": {
"type": "string",
"pattern": "^[a-z0-9-]{1,60}$",
"title": "Template"
},
"data": {
"additionalProperties": true,
"type": "object",
"title": "Data"
},
"profile": {
"$ref": "#/components/schemas/Profile",
"default": "color"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"type",
"template"
],
"title": "TemplateContent"
},
"Options": {
"properties": {
"duration": {
"type": "integer",
"maximum": 604800.0,
"minimum": 0.0,
"title": "Duration",
"default": 0
},
"restore_previous": {
"type": "boolean",
"title": "Restore Previous",
"default": false
},
"priority": {
"type": "integer",
"maximum": 100.0,
"minimum": 0.0,
"title": "Priority",
"default": 50
}
},
"additionalProperties": false,
"type": "object",
"title": "Options"
},
"Accepted": {
"properties": {
"operation_id": {
"type": "string",
"title": "Operation Id"
},
"job_id": {
"type": "string",
"title": "Job Id"
},
"status": {
"type": "string",
"const": "accepted",
"title": "Status",
"default": "accepted"
}
},
"type": "object",
"required": [
"operation_id",
"job_id"
],
"title": "Accepted"
},
"Operation": {
"properties": {
"operation_id": {
"type": "string",
"title": "Operation Id"
},
"status": {
"$ref": "#/components/schemas/OperationStatus"
},
"device_id": {
"type": "string",
"title": "Device Id"
},
"created_at": {
"type": "string",
"title": "Created At"
},
"updated_at": {
"type": "string",
"title": "Updated At"
},
"result": {
"anyOf": [
{
"$ref": "#/components/schemas/OperationResult"
},
{
"type": "null"
}
]
},
"error": {
"anyOf": [
{
"$ref": "#/components/schemas/ErrorBody"
},
{
"type": "null"
}
]
},
"request_id": {
"type": "string",
"title": "Request Id"
},
"client_id": {
"type": "string",
"title": "Client Id"
},
"action": {
"type": "string",
"title": "Action"
}
},
"type": "object",
"required": [
"operation_id",
"status",
"device_id",
"created_at",
"updated_at",
"request_id",
"client_id",
"action"
],
"title": "Operation"
},
"OperationResult": {
"properties": {
"image_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Image Id"
},
"asset_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Asset Id"
},
"rendered_asset_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Rendered Asset Id"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Url"
},
"deferred": {
"type": "boolean",
"title": "Deferred",
"default": false
}
},
"type": "object",
"title": "OperationResult"
},
"OperationStatus": {
"type": "string",
"enum": [
"QUEUED",
"RENDERING",
"UPLOADING",
"SYNCING",
"COMPLETED",
"FAILED",
"UNKNOWN"
],
"title": "OperationStatus"
},
"ErrorBody": {
"properties": {
"code": {
"$ref": "#/components/schemas/ErrorCode"
},
"message": {
"type": "string",
"title": "Message"
},
"request_id": {
"type": "string",
"title": "Request Id"
},
"details": {
"additionalProperties": true,
"type": "object",
"title": "Details"
}
},
"type": "object",
"required": [
"code",
"message",
"request_id"
],
"title": "ErrorBody"
},
"ErrorResponse": {
"properties": {
"error": {
"$ref": "#/components/schemas/ErrorBody"
}
},
"type": "object",
"required": [
"error"
],
"title": "ErrorResponse"
}
}
+347
View File
@@ -0,0 +1,347 @@
import hashlib
import json
from io import BytesIO
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from pigway_frame_flow.api.app import create_app
from pigway_frame_flow.settings import Settings
from pigway_frame_flow.storage.database import encode, now_iso
def test_health_ready(client):
assert client.get("/health").status_code == 200
assert client.get("/ready").status_code == 200
assert all(client.get("/ready").json()["checks"].values())
def test_without_external_project(client):
assert client.get("/").status_code == 200
assert client.get("/api/v1/devices").status_code == 200
assert len(client.get("/api/v1/templates").json()) == 6
assert client.get("/api/v1/jobs").status_code == 200
def test_auth(client):
assert (
client.get("/api/v1/devices", headers={"Authorization": ""}).status_code == 401
)
r = client.get("/api/v1/settings", headers={"Authorization": "Bearer wrong"})
assert r.status_code == 401
assert set(r.json()["error"]) == {"code", "message", "request_id", "details"}
def test_scope(client, service):
service.db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
"reader",
hashlib.sha256(b"reader").hexdigest(),
"Reader",
encode(["devices:read"]),
now_iso(),
None,
1,
),
)
assert (
client.get(
"/api/v1/devices", headers={"Authorization": "Bearer reader"}
).status_code
== 200
)
assert (
client.post(
"/api/v1/display",
headers={"Authorization": "Bearer reader"},
json={"content": {"type": "template", "template": "quote"}},
).status_code
== 403
)
def test_device_schema(client):
device = client.get("/api/v1/devices/living-room").json()
assert device["id"] == "living-room"
assert device["display"]["width"] == 1200
assert device["capabilities"]["color"] is True
assert device["slideshow"]["mode"] == "queue"
assert device["slideshow"]["members_available"] is False
assert "nickname" not in device
assert "api_key" not in json.dumps(device)
def test_all_cloud_capabilities(client, service):
response = client.post(
"/api/v1/display", json={"content": {"type": "template", "template": "quote"}}
)
assert response.status_code == 202
id = response.json()["operation_id"]
service.process_one()
service.sync()
assert client.get("/api/v1/operations/" + id).json()["status"] == "COMPLETED"
images = client.get("/api/v1/devices/living-room/images").json()
assert len(images) == 2
assert client.get(images[0]["url"] + "?thumbnail=true").status_code == 200
name = images[1]["name"]
named = client.post(
"/api/v1/display", json={"content": {"type": "image", "image_name": name}}
)
assert named.status_code == 202
service.process_one()
service.sync()
assert service.registry.get("living-room").get_current_image() == 1
def test_delete_current_confirmation(client):
assert client.delete("/api/v1/devices/living-room/images/1").status_code == 409
def test_preview_local_upload(client, service):
out = BytesIO()
Image.new("RGB", (100, 200), "blue").save(out, "PNG")
preview = client.post(
"/api/v1/preview",
files={"file": ("sample.png", out.getvalue(), "image/png")},
data={"fit": "contain", "profile": "grayscale"},
)
assert preview.status_code == 200
asset = preview.json()
assert client.get(asset["url"]).status_code == 200
r = client.post(
"/api/v1/devices/living-room/upload",
json={"type": "image", "asset_id": asset["asset_id"]},
)
assert r.status_code == 202
service.process_one()
assert service.registry.get("living-room").get_current_image() == 1
assert service.registry.get("living-room").client.uploads == 0
@pytest.mark.parametrize(
"content",
[{"type": "html", "data": "hello"}, {"type": "markdown", "data": "hello"}],
)
def test_unsupported(client, content):
r = client.post("/api/v1/display", json={"content": content})
assert r.status_code == 422
assert r.json()["error"]["code"] == "UNSUPPORTED_CAPABILITY"
def test_idempotency_api(client, service):
p = {"content": {"type": "template", "template": "quote"}}
a = client.post("/api/v1/display", json=p, headers={"Idempotency-Key": "repeat"})
b = client.post("/api/v1/display", json=p, headers={"Idempotency-Key": "repeat"})
assert a.json() == b.json()
service.process_one()
assert service.registry.get("living-room").client.uploads == 1
def test_request_id(client):
r = client.get(
"/api/v1/devices/no-device", headers={"X-Request-ID": "external-123"}
)
assert r.status_code == 404
assert r.headers["X-Request-ID"] == "external-123"
assert r.json()["error"]["request_id"] == "external-123"
def test_secret_never_returned(client, service):
assert "test-frame-token" not in client.get("/api/v1/settings").text
data = client.post(
"/api/v1/display",
json={
"content": {
"type": "template",
"template": "quote",
"data": {"title": "test-frame-token"},
}
},
)
row = service.db.one(
"SELECT payload FROM operations WHERE id=?", (data.json()["operation_id"],)
)
assert "test-frame-token" not in row["payload"]
assert "test-frame-token" not in str(service.settings)
def test_cors_default(client):
response = client.options(
"/api/v1/display",
headers={
"Origin": "https://external.example",
"Access-Control-Request-Method": "POST",
},
)
assert "access-control-allow-origin" not in response.headers
def test_missing_token_not_ready(tmp_path):
app = create_app(
Settings(
data_dir=tmp_path / "data", config_dir=tmp_path / "config", api_token=""
),
False,
)
with TestClient(app) as c:
assert c.get("/ready").status_code == 503
assert (
c.post(
"/api/v1/display",
json={"content": {"type": "template", "template": "quote"}},
).status_code
== 401
)
def test_live_read_only(tmp_path):
app = create_app(
Settings(
data_dir=tmp_path / "data",
config_dir=tmp_path / "config",
api_token="test",
api_key="fake-device-key",
mock=False,
write_enabled=False,
),
False,
)
with TestClient(app) as c:
c.headers["Authorization"] = "Bearer test"
assert c.get("/ready").status_code == 200
assert (
c.post(
"/api/v1/display",
json={"content": {"type": "template", "template": "quote"}},
).status_code
== 403
)
assert c.delete("/api/v1/devices/living-room/images/1").status_code == 403
assert c.post("/api/v1/devices/living-room/restore").status_code == 403
def test_multidevice(tmp_path):
config = tmp_path / "config"
config.mkdir()
(config / "devices.yml").write_text(
"devices:\n living-room:\n driver: kokonna\n office:\n driver: kokonna\n"
)
app = create_app(
Settings(data_dir=tmp_path / "data", config_dir=config, api_token="test"), False
)
with TestClient(app) as c:
c.headers["Authorization"] = "Bearer test"
assert len(c.get("/api/v1/devices").json()) == 2
assert (
c.post(
"/api/v1/devices/office/display",
json={"content": {"type": "image", "image_id": 1}},
).status_code
== 202
)
app.state.service.process_one()
assert app.state.service.registry.get("living-room").client.displays == []
assert app.state.service.registry.get("office").client.displays == [1]
def test_job_crud(client):
p = {
"name": "Morning",
"schedule": {"type": "daily", "time": "07:00"},
"source": {"type": "template", "template": "daily-dashboard"},
}
result = client.post("/api/v1/jobs", json=p)
assert result.status_code == 201
id = result.json()["id"]
p["enabled"] = False
assert client.put("/api/v1/jobs/" + id, json=p).json()["enabled"] is False
assert client.delete("/api/v1/jobs/" + id).status_code == 204
def test_contract(client, app):
schema = client.get("/openapi.json").json()
expected = json.loads((Path(__file__).parent / "contract.json").read_text())
actual = {
path: {method: sorted(spec["responses"]) for method, spec in methods.items()}
for path, methods in schema["paths"].items()
}
assert actual == expected
assert schema["components"]["securitySchemes"]["HTTPBearer"]["scheme"] == "bearer"
assert "Device" in schema["components"]["schemas"]
assert schema["components"]["schemas"]["OperationStatus"]["enum"] == [
"QUEUED",
"RENDERING",
"UPLOADING",
"SYNCING",
"COMPLETED",
"FAILED",
"UNKNOWN",
]
assert client.get("/docs").status_code == 200
def test_core_has_no_client_special_case():
for path in Path("pigway_frame_flow").rglob("*.py"):
assert "ticktar" not in path.read_text().lower()
assert (
"KokonnaClient" not in Path("pigway_frame_flow/display/service.py").read_text()
)
def test_ui_languages(client):
assert client.get("/ui/app.js").status_code == 200
i18n = client.get("/ui/i18n.js").text
assert "zh:" in i18n and "en:" in i18n
assert "data-theme=dark" in client.get("/ui/style.css").text
def test_local_asset_deletion(client, service):
asset = service.assets.put(service.renderer.render("quote", {}), "quote.jpg")
assert client.delete("/api/v1/assets/" + asset["asset_id"]).status_code == 204
assert client.get(asset["url"]).status_code == 404
def test_asset_reference_prevents_delete(client, service):
asset = service.assets.put(service.renderer.render("quote", {}), "photo.jpg")
service.submit(
"display",
{"content": {"type": "image", "asset_id": asset["asset_id"]}},
"living-room",
"admin",
"reference",
)
assert client.delete("/api/v1/assets/" + asset["asset_id"]).status_code == 409
def test_unknown_same_idempotency_key_returns_original(client, service):
from pigway_frame_flow.errors import ErrorCode, FlowError
payload = {"content": {"type": "template", "template": "quote"}}
def fail(*args):
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
service.registry.get("living-room").upload = fail
original = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "timeout-test"}
)
service.process_one()
assert service.operation(original.json()["operation_id"])["status"] == "UNKNOWN"
retry = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "timeout-test"}
)
assert retry.status_code == 202
assert retry.json() == original.json()
fresh = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "new-key"}
)
assert fresh.status_code == 409
def test_schema_contract(client):
schemas = client.get("/openapi.json").json()["components"]["schemas"]
expected = json.loads((Path(__file__).parent / "schema-contract.json").read_text())
assert {key: schemas[key] for key in expected} == expected
+120
View File
@@ -0,0 +1,120 @@
from unittest.mock import Mock
import pytest
import requests
from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
from pigway_frame_flow.errors import FlowError
def session(body=None, status=200):
s = Mock()
s.request.return_value.status_code = status
s.request.return_value.json.return_value = body or {}
s.request.return_value.content = b"image"
return s
def test_query():
s = session({"nickname": "Test"})
c = KokonnaClient("secret", session=s)
assert c.get_device()["nickname"] == "Test"
kw = s.request.call_args.kwargs
assert kw["headers"]["Authorization"] == "Bearer secret"
assert s.request.call_args.args[:2] == (
"POST",
"https://api.kokonna.art/openapi/device",
)
@pytest.mark.parametrize(
"endpoint,method,args",
[
("/listImages", "list_images", ()),
("/displayImageById", "display", (2,)),
("/deleteImage", "delete", (2,)),
("/displayImageByName", "display_by_name", ("art.jpg",)),
],
)
def test_endpoints(endpoint, method, args):
s = session({"success": True})
c = KokonnaClient("secret", True, s)
getattr(c, method)(*args)
assert s.request.call_args.args[1].endswith(endpoint)
def test_upload_payload(tmp_path):
path = tmp_path / "x.jpg"
path.write_bytes(b"jpeg")
s = session({"id": 10, "counter": 2})
c = KokonnaClient("secret", True, s)
assert c.upload(path, "art.jpg") == 10
assert s.request.call_args.kwargs["json"] == {
"base64": "anBlZw==",
"name": "art.jpg",
}
def test_download_not_leaked():
s = session()
c = KokonnaClient("super-secret", session=s)
assert c.download(12) == b"image"
assert s.request.call_args.kwargs["allow_redirects"] is False
@pytest.mark.parametrize(
"method,args",
[
("upload", (None, "x")),
("display", (1,)),
("delete", (1,)),
("display_by_name", ("x",)),
],
)
def test_write_disabled(method, args):
s = session()
c = KokonnaClient("secret", False, s)
with pytest.raises(FlowError, match="authorization"):
getattr(c, method)(*args)
s.request.assert_not_called()
@pytest.mark.parametrize("write", [False, True])
def test_timeout_redaction_no_retry(write):
s = session()
s.request.side_effect = requests.Timeout("secret https://host/secret")
c = KokonnaClient("secret", True, s)
with pytest.raises(FlowError) as e:
c.display(1) if write else c.get_device()
assert "secret" not in str(e.value)
assert e.value.code == ("WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE")
assert s.request.call_count == 1
@pytest.mark.parametrize("status", [401, 429, 500, 530])
def test_cloud_error_sanitized(status):
s = session({"message": "secret"}, status)
c = KokonnaClient("secret", True, s)
with pytest.raises(FlowError) as e:
c.get_device()
assert "secret" not in str(e.value)
def test_invalid_upload_id(tmp_path):
path = tmp_path / "x.jpg"
path.write_bytes(b"jpeg")
c = KokonnaClient("secret", True, session({"counter": 2}))
with pytest.raises(FlowError) as e:
c.upload(path, "x")
assert e.value.code == "WRITE_RESULT_UNKNOWN"
def test_rate_budget():
s = session({"nickname": "x"})
c = KokonnaClient("secret", session=s)
for _ in range(18):
c.get_device()
with pytest.raises(FlowError) as e:
c.get_device()
assert e.value.code == "RATE_LIMITED"
assert s.request.call_count == 18
+153
View File
@@ -0,0 +1,153 @@
from datetime import UTC, datetime, timedelta
import pytest
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode, FlowError
from pigway_frame_flow.models import DisplayRequest
def submit(service, duration=0, restore=False, priority=50, key=None, content=None):
request = DisplayRequest(
content=content or {"type": "template", "template": "quote", "data": {}},
options={
"duration": duration,
"restore_previous": restore,
"priority": priority,
},
)
return service.submit(
"display",
request.model_dump(mode="json"),
"living-room",
"admin",
"request-test",
key,
)
def expired(service, id):
service.db.execute(
"UPDATE overrides SET expires_at=? WHERE id=?",
((datetime.now(UTC) - timedelta(seconds=1)).isoformat(), id),
)
def test_display_restore(service):
id = submit(service, 60, True)
service.process_one()
service.sync()
assert service.operation(id)["status"] == "COMPLETED"
assert service.registry.get("living-room").get_current_image() == 2
expired(service, id)
service.expire()
service.sync()
assert service.registry.get("living-room").get_current_image() == 1
def test_priority_lower_defers_upload(service):
high = submit(service, 300, True, 90)
service.process_one()
low = submit(service, 60, True, 10)
service.process_one()
assert service.operation(low)["status"] == "QUEUED"
assert service.registry.get("living-room").client.uploads == 1
expired(service, high)
service.expire()
service.process_one()
assert service.registry.get("living-room").client.uploads == 2
def test_nested_overrides(service):
a = submit(service, 300, True, 10)
service.process_one()
b = submit(service, 60, True, 90)
service.process_one()
expired(service, b)
service.expire()
assert service.registry.get("living-room").get_current_image() == 2
expired(service, a)
service.expire()
assert service.registry.get("living-room").get_current_image() == 1
def test_no_retry_unknown(service):
driver = service.registry.get("living-room")
def timeout(*a):
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
driver.upload = timeout
id = submit(service)
service.process_one()
assert service.operation(id)["status"] == "UNKNOWN"
with pytest.raises(FlowError):
submit(service)
assert service.process_one() is False
assert service.reconcile(id)["resolved"] is False
assert service.reconcile(id, True)["resolved"] is True
def test_restart_preserves_override(service):
id = submit(service, 60, True)
service.process_one()
restarted = DisplayService(
service.db, service.settings, service.registry, service.assets, service.renderer
)
expired(restarted, id)
restarted.expire()
assert service.registry.get("living-room").get_current_image() == 1
def test_restart_marks_inflight_unknown(service):
id = submit(service)
service._start_write(id)
DisplayService(
service.db, service.settings, service.registry, service.assets, service.renderer
)
assert service.operation(id)["status"] == "UNKNOWN"
def test_idempotency(service):
first = submit(service, key="repeat")
second = submit(service, key="repeat")
assert first == second
service.process_one()
service.process_one()
assert service.registry.get("living-room").client.uploads == 1
with pytest.raises(FlowError):
submit(service, priority=10, key="repeat")
def test_manual_change_prevents_restore(service):
id = submit(service, 60, True)
service.process_one()
driver = service.registry.get("living-room")
driver.client.display(1)
driver.invalidate()
expired(service, id)
service.expire()
assert driver.get_current_image() == 1
assert not service.db.all("SELECT * FROM overrides")
def test_delete_restore_reference_protected(service):
submit(service, 60, True)
service.process_one()
delete = service.submit(
"delete",
{"image_id": 1, "confirm_current": True},
"living-room",
"admin",
"delete-test",
)
service.process_one()
assert service.operation(delete)["status"] == "FAILED"
assert service.operation(delete)["error"]["code"] == "CONFLICT"
def test_offline(service):
service.registry.get("living-room").client.online = False
id = submit(service)
service.process_one()
assert service.operation(id)["error"]["code"] == "DEVICE_OFFLINE"
+70
View File
@@ -0,0 +1,70 @@
from io import BytesIO
import pytest
from PIL import Image
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.renderers.images import convert
def image_bytes(format="PNG", size=(800, 400)):
out = BytesIO()
Image.new("RGB", size, "red").save(out, format)
return out.getvalue()
@pytest.mark.parametrize("format", ["JPEG", "PNG", "WEBP"])
@pytest.mark.parametrize("fit", ["contain", "cover", "smart"])
@pytest.mark.parametrize("profile", ["color", "grayscale"])
def test_conversion(format, fit, profile):
img = Image.open(BytesIO(convert(image_bytes(format), fit, profile)))
assert img.size == (1200, 1600)
assert img.format == "JPEG"
if profile == "grayscale":
assert len(set(img.getpixel((600, 800)))) == 1
def test_contain_preserves_shape():
img = Image.open(BytesIO(convert(image_bytes(), "contain")))
assert img.getpixel((600, 100)) == (255, 255, 255)
assert img.getpixel((600, 800))[0] > 240
def test_invalid_image():
with pytest.raises(FlowError):
convert(b"bad")
def test_transparency_white():
out = BytesIO()
Image.new("RGBA", (100, 100), (255, 0, 0, 0)).save(out, "PNG")
img = Image.open(BytesIO(convert(out.getvalue(), "cover")))
assert img.getpixel((600, 800)) == (255, 255, 255)
@pytest.mark.parametrize(
"template",
["daily-dashboard", "weather", "calendar", "quote", "photo-text", "system-alert"],
)
def test_templates(service, template):
result = service.renderer.render(template, {}, "color")
assert Image.open(BytesIO(result)).size == (1200, 1600)
def test_url_default_disabled(service):
with pytest.raises(FlowError):
service.assets.fetch_url("https://example.com/x.jpg")
@pytest.mark.parametrize(
"url",
[
"http://localhost/x",
"file:///etc/passwd",
"https://127.0.0.1/x",
"https://user:pass@example.com/x",
],
)
def test_url_ssrf(service, url):
with pytest.raises(FlowError):
service.assets.fetch_url(url)
+77
View File
@@ -0,0 +1,77 @@
from datetime import UTC, datetime, timedelta
import pytest
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import JobInput, PlaylistInput, Schedule
from pigway_frame_flow.scheduler.engine import next_run
@pytest.mark.parametrize("kind,day", [("daily", 4), ("weekdays", 4), ("weekends", 6)])
def test_schedules(kind, day):
start = datetime(2026, 10, 1, 0, 0, tzinfo=UTC)
result = datetime.fromisoformat(next_run(Schedule(type=kind, time="09:00"), start))
assert result.weekday() + 1 == day
def test_cron():
result = next_run(
Schedule(type="cron", cron="0 7 * * *"), datetime(2026, 10, 1, 0, 0, tzinfo=UTC)
)
assert result.startswith("2026-10-01T23:00")
@pytest.mark.parametrize("type", ["date", "temporary"])
def test_one_time(type):
current = datetime.now(UTC)
at = current + timedelta(hours=1)
assert next_run(Schedule(type=type, at=at), current) == at.isoformat()
assert next_run(Schedule(type=type, at=at), at) is None
def test_dedupe(app, service):
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Morning",
schedule={"type": "daily", "time": "07:00"},
source={"type": "template", "template": "quote"},
)
)
due = (datetime.now(UTC) - timedelta(seconds=2)).isoformat()
service.db.execute("UPDATE jobs SET next_run=? WHERE id=?", (due, job["id"]))
scheduler.tick()
scheduler.tick()
assert len(service.db.all("SELECT * FROM operations")) == 1
assert len(service.db.all("SELECT * FROM job_runs")) == 1
def test_overdue_skipped(app, service):
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Old",
schedule={"type": "daily", "time": "07:00"},
source={"type": "restore"},
)
)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?",
((datetime.now(UTC) - timedelta(hours=1)).isoformat(), job["id"]),
)
scheduler.tick()
assert scheduler.jobs()[0]["last_result"] == "SKIPPED_OVERDUE"
assert not service.db.all("SELECT * FROM operations")
def test_playlist(app, service):
scheduler = app.state.scheduler
scheduler.save_playlist(PlaylistInput(name="Art", image_ids=[1], enabled=True))
scheduler.tick()
scheduler.tick()
assert len(service.db.all("SELECT * FROM operations")) == 1
def test_invalid_cron():
with pytest.raises(FlowError):
next_run(Schedule(type="cron", cron="* * * * * *"))
View File
+243
View File
@@ -0,0 +1,243 @@
"""
KoKonna 画框控制库
用法:
from kokonna import KoKonnaFrame
frame = KoKonnaFrame(device="living_room")
frame.upload_image("/path/to/image.jpg")
info = frame.get_device_info()
"""
import base64
import os
from io import BytesIO
from pathlib import Path
from typing import Optional, Dict, Any
import requests
from PIL import Image
# 默认 API 地址
DEFAULT_API_BASE_URL = "https://api.galaxyguide.cn/openapi"
# 配置文件路径
CONFIG_PATHS = [
Path.home() / ".openclaw" / "skills" / "kokonna-frame" / "config.yaml",
Path.home() / ".openclaw" / "skills" / "kokonna-frame" / "config.yml",
Path.cwd() / "config.yaml",
]
def load_config() -> Dict[str, Any]:
"""从配置文件加载配置"""
import yaml
for config_path in CONFIG_PATHS:
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {}
# 没有配置文件,返回空配置
return {}
def get_api_base_url() -> str:
"""获取 API Base URL"""
config = load_config()
return config.get("api_base_url", DEFAULT_API_BASE_URL)
def get_device_config(device_name: str) -> Dict[str, Any]:
"""获取指定设备的配置"""
config = load_config()
devices = config.get("devices", {})
if device_name not in devices:
available = list(devices.keys())
if available:
raise ValueError(f"未知设备: {device_name},可用设备: {available}")
else:
raise ValueError(f"未知设备: {device_name},未配置任何设备。请在 config.yaml 中添加设备配置。")
device_config = devices[device_name]
# device_config 应该是 API Key 字符串,或者是包含详细配置的字典
if isinstance(device_config, str):
return {"api_key": device_config}
else:
return device_config
def generate_filename(image_source=None, name_hint: str = None) -> str:
"""生成有意义的文件名"""
from datetime import datetime
import re
if name_hint:
# 如果提供了名称提示,使用它
# 清理文件名:只保留中文、英文、数字、下划线、连字符
clean_name = re.sub(r'[^\w\u4e00-\u9fff\-]', '_', name_hint)
clean_name = re.sub(r'_+', '_', clean_name).strip('_')
if clean_name:
return f"{clean_name}.jpg"
# 从原文件名提取有意义部分
if image_source and isinstance(image_source, (str, Path)):
basename = Path(image_source).name
name_without_ext = Path(image_source).stem
# 去掉时间戳类的后缀
clean_name = re.sub(r'[_\-]?\d{6,}[_\-]?\d*$', '', name_without_ext)
clean_name = re.sub(r'[_\-]?\d{10,}', '', clean_name)
if clean_name and len(clean_name) > 2:
return f"{clean_name}.jpg"
# 默认使用时间戳
return datetime.now().strftime("%Y%m%d_%H%M%S") + ".jpg"
class KoKonnaFrame:
"""KoKonna 画框控制类"""
def __init__(self, device: str, width: int = None, height: int = None):
"""
初始化画框
Args:
device: 设备名称(在 config.yaml 中配置)
width: 画框宽度(可选,如未配置会自动从设备获取)
height: 画框高度(可选,如未配置会自动从设备获取)
"""
self.device_key = device
self.api_base_url = get_api_base_url()
device_config = get_device_config(device)
self.api_key = device_config.get("api_key")
if not self.api_key:
raise ValueError(f"设备 {device} 未配置 api_key")
self.width = width or device_config.get("width")
self.height = height or device_config.get("height")
# 如果没有配置尺寸,尝试从设备获取
if not self.width or not self.height:
info = self.get_device_info()
if info:
# 根据旋转角度确定实际显示尺寸
rotation = info.get("rotation", 0)
screen_width = info.get("screen_width", 480)
screen_height = info.get("screen_height", 800)
if rotation in [90, 270]:
self.width = screen_height
self.height = screen_width
else:
self.width = screen_width
self.height = screen_height
def _resize_image(self, image_source) -> bytes:
"""调整图片尺寸"""
if isinstance(image_source, (str, Path)):
img = Image.open(image_source)
else:
img = Image.open(image_source)
# 转换颜色模式
if img.mode == "RGBA":
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
# 如果没有尺寸信息,直接返回原图
if not self.width or not self.height:
buffer = BytesIO()
img.save(buffer, format="JPEG", quality=95)
return buffer.getvalue()
# 裁剪到目标比例
target_ratio = self.width / self.height
w, h = img.size
current_ratio = w / h
if current_ratio > target_ratio:
# 图片太宽,裁剪两边
new_w = int(h * target_ratio)
left = (w - new_w) // 2
img = img.crop((left, 0, left + new_w, h))
elif current_ratio < target_ratio:
# 图片太高,裁剪上下
new_h = int(w / target_ratio)
top = (h - new_h) // 2
img = img.crop((0, top, w, top + new_h))
# 缩放到目标尺寸
img = img.resize((self.width, self.height), Image.LANCZOS)
# 保存为 JPEG
buffer = BytesIO()
img.save(buffer, format="JPEG", quality=95)
return buffer.getvalue()
def get_device_info(self) -> Optional[Dict]:
"""获取设备信息"""
url = f"{self.api_base_url}/device"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
try:
response = requests.post(url, headers=headers, json={}, timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"获取设备信息失败: {e}")
return None
def upload_image(self, image_source, name: str = None) -> Dict:
"""
上传图片到画框
Args:
image_source: 图片路径或文件对象
name: 图片名称(可选)
Returns:
API 响应
"""
# 生成文件名
filename = generate_filename(image_source, name)
# 处理图片
image_data = self._resize_image(image_source)
base64_data = base64.b64encode(image_data).decode('utf-8')
# 上传
url = f"{self.api_base_url}/upload"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"base64": base64_data,
"name": filename,
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
return response.json()
# 便捷函数
def upload_to_device(device: str, image_path: str, name: str = None) -> Dict:
"""上传图片到指定设备"""
frame = KoKonnaFrame(device=device)
return frame.upload_image(image_path, name=name)
def get_device_info(device: str) -> Optional[Dict]:
"""获取指定设备信息"""
frame = KoKonnaFrame(device=device)
return frame.get_device_info()
+14
View File
@@ -0,0 +1,14 @@
"""MIT-0 guooscar/kokonna-frame 1.2.0 upload protocol adaptation.
Original upload payload: raw base64 + name, POST /upload, Bearer auth.
Transport and pixel preparation are injected; upstream snapshot is in vendor/.
"""
import base64
def upload(transport, image, name):
return transport.request('/upload', {'base64': base64.b64encode(image).decode('utf-8'), 'name': name}, write=True)
def device(transport):
return transport.request('/device', {})
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "kn76j838cn9zrnqe7my6cq291d84szyr",
"slug": "kokonna-frame",
"version": "1.2.0",
"publishedAt": 1776046944415
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "swagger-ui-dist",
"version": "5.33.1",
"license": "Apache-2.0",
"source": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz",
"integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA=="
}