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