From e67b4ceb6e01cf880f9e32bf2143ed7c72f3c82d Mon Sep 17 00:00:00 2001 From: Pigway Date: Fri, 2 Oct 2026 09:19:25 +0800 Subject: [PATCH] feat: content provenance and complete frame management workflows --- NOTICE.md | 9 + README.md | 30 ++++ pigway_frame_flow/api/app.py | 156 +++++++++++++++++- pigway_frame_flow/display/service.py | 65 +++++++- pigway_frame_flow/models.py | 44 +++++ pigway_frame_flow/providers/weather.py | 135 +++++++++++++++ pigway_frame_flow/renderers/templates.py | 28 +++- pigway_frame_flow/storage/database.py | 3 + .../templates/daily-dashboard.json | 11 ++ pigway_frame_flow/templates/weather.json | 11 ++ pigway_frame_flow/web/app.js | 12 +- pigway_frame_flow/web/features.js | 63 +++++++ pigway_frame_flow/web/i18n.js | 6 + pigway_frame_flow/web/index.html | 2 +- scripts/ui-smoke.cjs | 7 +- tests/contract.json | 140 ++++++++++++++++ tests/test_content_provenance.py | 87 ++++++++++ tests/test_weather.py | 127 ++++++++++++++ 18 files changed, 918 insertions(+), 18 deletions(-) create mode 100644 pigway_frame_flow/web/features.js create mode 100644 tests/test_content_provenance.py create mode 100644 tests/test_weather.py diff --git a/NOTICE.md b/NOTICE.md index 0783ac4..ce7f0f4 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -31,3 +31,12 @@ third-party code, fonts, or trademarks. Dependency versions are in requirements. 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. + +## Open-Meteo weather integration + +The optional provider consumes the public Weather Forecast API, implemented here +without copying a provider SDK. API documentation: https://open-meteo.com/en/docs . +Weather data attribution: Open-Meteo, CC BY 4.0. Attribution is retained in provider +results, UI and weather/dashboard output. Public API usage is intended for this +non-commercial household deployment; commercial deployments must review provider +terms. This service is not affiliated with Open-Meteo. diff --git a/README.md b/README.md index a855806..13a4fa6 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,33 @@ until real writes are separately approved. Browser smoke tests use a disposable Mock account via FRAME_FLOW_TEST_USERNAME and FRAME_FLOW_TEST_PASSWORD; never run the mutation smoke suite against live devices. + +## Current content and completed page workflows + +Dashboard displays the actual current image and recorded content source. Frame Flow +stores immutable template/data/profile snapshots when it renders an asset and maps +successful upload results to the device/image ID. The current-content endpoint +matches the live device image ID; vendor images with no recorded source remain +explicitly unknown. Switching externally never makes the previous template appear +as the current one. Restore also restores the matching content provenance. + +Templates can load the current recorded template or continue editing a generated +local image. They support direct photo import and protected image downloads. +Gallery provides search, sorting, dimensions and downloads. Display confirmation +supports duration, priority and restoration. History includes operation details. +Automation offers content pickers, template profiles, previews and manual execution; +playlists offer gallery image checkboxes. Real writes remain blocked by the server +regardless of the UI controls. + +Settings persists weather coordinates/provider and image URL host allowlists in +SQLite. Open-Meteo provides current model weather and a three-day forecast, cached +for ten minutes on demand. Network failures show unavailable/stale data and use a +two-minute backoff, without replacing it with fictitious live data. Default remains +Mock until configured in Settings. Open-Meteo attribution is included in the UI and +weather/dashboard renders. Image URL import is HTTPS-only with explicit hostnames, +public-address validation and pinned DNS; redirects and private addresses are blocked. + +Additional v1 endpoints: `/devices/{id}/current-content`, `/assets/{id}/source`, +`/providers/weather`, `/settings/weather`, `/settings/image-url`, `/jobs/{id}`, +`/jobs/{id}/preview`, `/jobs/{id}/run` (all under `/api/v1`). Existing contracts remain +compatible; new endpoints are included in generated OpenAPI and contract tests. diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 9a41165..8b30700 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -23,6 +23,7 @@ from pigway_frame_flow.errors import FlowError from pigway_frame_flow.models import ( Accepted, Asset, + CurrentDisplay, Device, DisplayRequest, ErrorResponse, @@ -31,9 +32,11 @@ from pigway_frame_flow.models import ( Health, History, ImageContent, + ImageURLConfig, Job, JobInput, Operation, + Options, Playlist, PlaylistInput, Profile, @@ -46,8 +49,10 @@ from pigway_frame_flow.models import ( TemplateInfo, URLContent, Version, + WeatherConfig, + WeatherView, ) -from pigway_frame_flow.providers.weather import MockWeatherProvider +from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider 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 @@ -61,9 +66,17 @@ def create_app(settings=None, start_worker=True): (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") + import json + + image_config = db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'") + if image_config: + settings.image_url_hosts = ImageURLConfig.model_validate( + json.loads(image_config["payload"]) + ).allowed_hosts registry = DeviceRegistry(settings) assets = Assets(db, settings) - renderer = TemplateRenderer(db, settings, MockWeatherProvider(), assets) + weather = ConfigurableWeatherProvider(db) + renderer = TemplateRenderer(db, settings, weather, assets) service = DisplayService(db, settings, registry, assets, renderer) scheduler = Scheduler(service) seed_client(db, settings.api_token) @@ -737,9 +750,145 @@ def create_app(settings=None, start_worker=True): api_token_configured=bool(settings.api_token), timezone=settings.timezone, device_id=settings.device_id, - weather_provider="mock", + weather_provider=weather.config().provider, ) + @app.get( + "/api/v1/devices/{device_id}/current-content", + response_model=CurrentDisplay, + tags=["Devices"], + dependencies=[Depends(require("devices:read"))], + ) + def current_content(device_id: str): + return service.current_content(device_id) + + @app.get( + "/api/v1/providers/weather", + response_model=WeatherView, + tags=["Providers"], + dependencies=[Depends(require("templates:read"))], + ) + def weather_view(): + values = weather.fetch() + return WeatherView(config=weather.config(), **values) + + @app.put( + "/api/v1/settings/weather", + response_model=WeatherConfig, + tags=["Settings"], + dependencies=[Depends(require("settings:write"))], + ) + def weather_config(payload: WeatherConfig): + return weather.configure(payload) + + @app.get( + "/api/v1/assets/{asset_id}/source", + response_model=dict[str, Any], + tags=["Templates"], + dependencies=[Depends(require("templates:read"))], + ) + def asset_source(asset_id: str): + assets.info(asset_id) + row = db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)) + import json + + return { + "source_known": bool(row), + "source": json.loads(row["payload"]) if row else None, + } + + @app.get( + "/api/v1/jobs/{job_id}", + response_model=Job, + tags=["Automation"], + dependencies=[Depends(require("jobs:read"))], + ) + def get_job(job_id: str): + job = next((j for j in scheduler.jobs() if j["id"] == job_id), None) + if not job: + raise FlowError(E.NOT_FOUND, "Job not found", 404) + return job + + @app.post( + "/api/v1/jobs/{job_id}/run", + response_model=Accepted, + status_code=202, + tags=["Automation"], + dependencies=[Depends(require("jobs:write"))], + ) + def run_job( + job_id: str, + request: Request, + idempotency_key: str | None = Header(default=None), + ): + job = JobInput.model_validate( + {k: v for k, v in get_job(job_id).items() if k in JobInput.model_fields} + ) + write_guard(job.device_id) + if job.source.type == "restore": + return submit(request, "restore", {}, job.device_id, idempotency_key) + payload = DisplayRequest( + device_id=job.device_id, + content=job.source, + options=Options( + duration=job.display_duration, + restore_previous=job.restore_previous, + priority=job.priority, + ), + ) + service.validate(payload) + return submit( + request, + "display", + payload.model_dump(mode="json"), + job.device_id, + idempotency_key, + ) + + @app.post( + "/api/v1/jobs/{job_id}/preview", + response_model=Accepted, + status_code=202, + tags=["Automation"], + dependencies=[Depends(require("render:write"))], + ) + def preview_job( + job_id: str, + request: Request, + idempotency_key: str | None = Header(default=None), + ): + source = get_job(job_id)["source"] + if source["type"] != "template": + raise FlowError( + E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422 + ) + return submit( + request, "render", source, get_job(job_id)["device_id"], idempotency_key + ) + + @app.get( + "/api/v1/settings/image-url", + response_model=ImageURLConfig, + tags=["Settings"], + dependencies=[Depends(require("admin"))], + ) + def image_url_config(): + return ImageURLConfig(allowed_hosts=settings.image_url_hosts) + + @app.put( + "/api/v1/settings/image-url", + response_model=ImageURLConfig, + tags=["Settings"], + dependencies=[Depends(require("settings:write"))], + ) + def set_image_url_config(payload: ImageURLConfig): + settings.image_url_hosts = payload.allowed_hosts + db.execute( + "INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)", + (encode(payload.model_dump()),), + ) + return payload + @app.get("/docs", include_in_schema=False) def api_docs(): return get_swagger_ui_html( @@ -764,6 +913,7 @@ def create_app(settings=None, start_worker=True): def static(filename: str): if filename not in [ "app.js", + "features.js", "style.css", "i18n.js", "swagger-ui-bundle.js", diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 777866f..2cb5933 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -153,8 +153,20 @@ class DisplayService: 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") + values = self.renderer.resolve(content.template, content.data) + data = self.renderer.render(content.template, values, content.profile) + asset = self.assets.put(data, content.template + ".jpg", "generated") + source = { + "type": "template", + "template": content.template, + "data": self.settings.sanitize(values), + "profile": content.profile, + } + self.db.execute( + "INSERT OR REPLACE INTO asset_sources VALUES(?,?)", + (asset["asset_id"], encode(source)), + ) + return asset if isinstance(content, URLContent): data = self.assets.fetch_url(content.url) elif isinstance(content, ImageContent) and content.asset_id: @@ -167,6 +179,38 @@ class DisplayService: convert(data, content.fit, content.profile), content.name ) + def current_content(self, device_id): + device = self.registry.get(device_id).device() + image_id = device.current_content.id + row = self.db.one( + "SELECT * FROM image_sources WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) + source = json.loads(row["payload"]) if row else None + # Acknowledged upload is not proof that the frame has refreshed yet. + if row: + op = self.operation(row["operation_id"]) + if op["status"] not in ("COMPLETED", "SYNCING"): + source = None + state = self.state(device_id) + return { + "device_id": device_id, + "image_id": image_id, + "synced": device.current_content.synced, + "source_known": source is not None, + "source": source, + "asset_id": row["asset_id"] if row and source else None, + "operation_id": row["operation_id"] if row and source else None, + "preview_url": f"/api/v1/devices/{device_id}/images/{image_id}/file?thumbnail=true" + if image_id + else None, + "previous_image_id": state["previous_image_id"], + "overrides": self.db.all( + "SELECT id,image_id,priority,expires_at FROM overrides WHERE device_id=? ORDER BY priority DESC,created_at DESC", + (device_id,), + ), + } + 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", @@ -268,6 +312,14 @@ class DisplayService: raise FlowError(E.CONFLICT, "Current image needs confirmation", 409) self._start_write(id) driver.delete(image_id) + for suffix in [".original", "-thumb.jpg"]: + ( + self.settings.data_dir / "cache" / f"{device_id}-{image_id}{suffix}" + ).unlink(missing_ok=True) + self.db.execute( + "DELETE FROM image_sources WHERE device_id=? AND image_id=?", + (device_id, image_id), + ) driver.invalidate() self.update(id, "COMPLETED", {"image_id": image_id}) self.db.history(op, "COMPLETED") @@ -334,6 +386,15 @@ class DisplayService: if asset: self.assets.promote(asset["asset_id"]) result["rendered_asset_id"] = asset["asset_id"] + source = self.db.one( + "SELECT payload FROM asset_sources WHERE asset_id=?", + (asset["asset_id"],), + ) + descriptor = source["payload"] if source else encode({"type": "image"}) + self.db.execute( + "INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)", + (device_id, image_id, asset["asset_id"], id, descriptor), + ) self.update(id, "SYNCING", result) driver.invalidate() if request.options.restore_previous: diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 6ce3178..3b0157f 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -346,3 +346,47 @@ class Reconciliation(BaseModel): expected_image_id: int | None cloud_images: list[GalleryImage] resolved: bool + + +class CurrentDisplay(BaseModel): + device_id: str + image_id: int | None + synced: bool + source_known: bool + source: dict[str, Any] | None + asset_id: str | None + operation_id: str | None + preview_url: str | None + previous_image_id: int | None + overrides: list[dict[str, Any]] + + +class WeatherConfig(StrictModel): + provider: Literal["mock", "open-meteo"] = "mock" + location: str = Field( + default="示例地点 / Shanghai Jinshan", min_length=1, max_length=120 + ) + latitude: float = Field(default=30.74, ge=-90, le=90) + longitude: float = Field(default=121.34, ge=-180, le=180) + + +class WeatherView(BaseModel): + config: WeatherConfig + current: dict[str, Any] + forecast: list[dict[str, Any]] + + +class ImageURLConfig(StrictModel): + allowed_hosts: list[str] = Field(default_factory=list, max_length=30) + + @model_validator(mode="after") + def hosts(self): + import re + + if any( + not re.fullmatch(r"[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,251}[a-zA-Z0-9])?", h) + for h in self.allowed_hosts + ): + raise ValueError("Use hostnames only, without URLs or wildcards") + self.allowed_hosts = sorted(set(h.lower() for h in self.allowed_hosts)) + return self diff --git a/pigway_frame_flow/providers/weather.py b/pigway_frame_flow/providers/weather.py index 93a9083..f3c18cb 100644 --- a/pigway_frame_flow/providers/weather.py +++ b/pigway_frame_flow/providers/weather.py @@ -17,3 +17,138 @@ class MockWeatherProvider: def get_forecast(self): return [] + + +class ConfigurableWeatherProvider: + """On-demand public weather with a ten-minute cache and bounded error backoff.""" + + def __init__(self, db, transport=None): + import threading + + import requests + + self.db = db + self.transport = transport or requests.get + self.lock = threading.Lock() + self.cache = None + self.next_fetch = 0.0 + self.cached_config = None + + def config(self): + import json + + from pigway_frame_flow.models import WeatherConfig + + row = self.db.one("SELECT payload FROM app_config WHERE key='weather'") + return WeatherConfig.model_validate(json.loads(row["payload"]) if row else {}) + + def configure(self, config): + from pigway_frame_flow.storage.database import encode + + with self.lock: + self.db.execute( + "INSERT OR REPLACE INTO app_config VALUES('weather',?)", + (encode(config.model_dump()),), + ) + self.cache = None + self.next_fetch = 0 + self.cached_config = None + return config + + @staticmethod + def summary(code): + if code == 0: + return "晴 / Clear" + if code in (1, 2, 3): + return "多云 / Cloudy" + if code in (45, 48): + return "雾 / Fog" + if code in (71, 73, 75, 77, 85, 86): + return "雪 / Snow" + if code in (95, 96, 99): + return "雷雨 / Thunderstorm" + if code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82): + return "雨 / Rain" + return "未知 / Unknown" + + def fetch(self): + import copy + import time + + config = self.config() + if config.provider == "mock": + current = MockWeatherProvider().get_current() + current["location"] = config.location + return {"current": current, "forecast": []} + with self.lock: + if self.cached_config != config: + self.cache = None + self.next_fetch = 0 + self.cached_config = config + if time.monotonic() < self.next_fetch: + return copy.deepcopy(self.cache) + try: + response = self.transport( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": config.latitude, + "longitude": config.longitude, + "current": "temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m", + "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max", + "timezone": "Asia/Shanghai", + "forecast_days": 3, + }, + timeout=8, + allow_redirects=False, + ) + response.raise_for_status() + raw = response.json() + cur, daily = raw["current"], raw["daily"] + current = { + "location": config.location, + "temperature": f"{cur['temperature_2m']}°C", + "summary": self.summary(cur["weather_code"]), + "humidity": cur.get("relative_humidity_2m"), + "wind_kmh": cur.get("wind_speed_10m"), + "observed_at": cur["time"] + "+08:00", + "mock": False, + "stale": False, + "available": True, + "attribution": "Open-Meteo / CC BY 4.0", + } + forecast = [ + { + "date": day, + "summary": self.summary(daily["weather_code"][i]), + "minimum_c": daily["temperature_2m_min"][i], + "maximum_c": daily["temperature_2m_max"][i], + "rain_probability": daily["precipitation_probability_max"][i], + } + for i, day in enumerate(daily["time"]) + ] + self.cache = {"current": current, "forecast": forecast} + self.next_fetch = time.monotonic() + 600 + except Exception: + if self.cache: + self.cache["current"]["stale"] = True + else: + self.cache = { + "current": { + "location": config.location, + "temperature": "--", + "summary": "天气暂不可用 / Weather unavailable", + "mock": False, + "stale": True, + "available": False, + "attribution": "Open-Meteo / CC BY 4.0", + }, + "forecast": [], + } + self.next_fetch = time.monotonic() + 120 + return copy.deepcopy(self.cache) + + def get_current(self): + return self.fetch()["current"] + + def get_forecast(self): + return self.fetch()["forecast"] diff --git a/pigway_frame_flow/renderers/templates.py b/pigway_frame_flow/renderers/templates.py index d24505e..ca62ee9 100644 --- a/pigway_frame_flow/renderers/templates.py +++ b/pigway_frame_flow/renderers/templates.py @@ -46,7 +46,7 @@ class TemplateRenderer: return ImageFont.truetype(path, size) return ImageFont.load_default(size=size) - def render(self, template_id, data, profile="color"): + def resolve(self, template_id, data): spec = self.info(template_id) conf = self.db.one( "SELECT payload FROM template_config WHERE template_id=?", (template_id,) @@ -69,8 +69,20 @@ class TemplateRenderer: "星期日 / Sunday", ][current.weekday()], ) - values.setdefault("weather", self.weather.get_current()) + if "weather" not in values: + values["weather"] = self.weather.get_current() values["updated"] = current.strftime("%Y-%m-%d %H:%M") + values["weather_attribution"] = ( + values.get("weather", {}).get("attribution", "") + if isinstance(values.get("weather"), dict) + else "" + ) + return values + + def render(self, template_id, data, profile="color"): + spec = self.info(template_id) + values = self.resolve(template_id, data) + current = datetime.now(ZoneInfo(self.settings.timezone)) img = Image.new("RGB", (1200, 1600), "white") draw = ImageDraw.Draw(img) accent = "#184d42" if profile == "color" else "#000000" @@ -156,7 +168,17 @@ class TemplateRenderer: for v in value[:10] ) if isinstance(value, dict): - value = " · ".join(str(v) for k, v in value.items() if k != "mock") + if block.get("field") == "weather": + value = " · ".join( + str(value.get(k, "")) + for k in ["location", "temperature", "summary"] + ) + if values["weather"].get("stale"): + value += " · 数据过期 / Stale" + else: + 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") diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py index 5c623b3..3e3fd5d 100644 --- a/pigway_frame_flow/storage/database.py +++ b/pigway_frame_flow/storage/database.py @@ -23,6 +23,9 @@ class Database: PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS asset_sources (asset_id TEXT PRIMARY KEY, payload TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id)); + CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL); 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, diff --git a/pigway_frame_flow/templates/daily-dashboard.json b/pigway_frame_flow/templates/daily-dashboard.json index bb13d34..edfa3a5 100644 --- a/pigway_frame_flow/templates/daily-dashboard.json +++ b/pigway_frame_flow/templates/daily-dashboard.json @@ -119,6 +119,17 @@ ], "size": 42, "kind": "photo" + }, + { + "kind": "text", + "field": "weather_attribution", + "box": [ + 72, + 1448, + 1056, + 45 + ], + "size": 24 } ] } diff --git a/pigway_frame_flow/templates/weather.json b/pigway_frame_flow/templates/weather.json index 3f84050..7136f5c 100644 --- a/pigway_frame_flow/templates/weather.json +++ b/pigway_frame_flow/templates/weather.json @@ -80,6 +80,17 @@ ], "size": 44, "kind": "text" + }, + { + "kind": "text", + "field": "weather_attribution", + "box": [ + 72, + 1448, + 1056, + 45 + ], + "size": 24 } ] } diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index 50b3d2f..7a65e12 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -30,7 +30,7 @@ function notice(){if(!state.settings)return '';return `'; - try{await views[state.page](epoch);guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`
${t('apiError')}

${esc(e.message)}

${buttons('retry','refresh')}
`;bind('retry',()=>navigate());} + try{await views[state.page](epoch);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,gallery:enhanceGallery,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`
${t('apiError')}

${esc(e.message)}

${buttons('retry','refresh')}
`;bind('retry',()=>navigate());} } function login(){ $('#main').innerHTML=`
FRAME FLOW

${t('connect')}

${t('tokenHint')}

`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});} // LAN HTTP is not a secure context; getRandomValues remains available there. @@ -56,17 +56,17 @@ async function templateEditor(selected){ function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`

${t('changePassword')}

`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});} 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?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${t('testLabel')}
${[['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])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${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'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;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()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;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(`

${t('displayByName')}

`);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(`

${esc(x.name)}

${esc(x.name)}`);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-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}, - async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;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='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',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);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()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${['☀','▦','◒','▣','❝','☁'][i]}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;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};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}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='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});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}}));await templateEditor(selected);}, + 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()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;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(`

${t('displayByName')}

`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return displayDialog(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(`

${esc(x.name)}

${esc(x.name)}`);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-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});}, + async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;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='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({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;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`
${t('templateHint')}
${state.templates.map((x,i)=>`
${['☀','▦','◒','▣','❝','☁'][i]}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('previewHint')}
`;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};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}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='Template preview';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});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',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`

Asia/Shanghai · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>``).join('')}${jobs.map((j,i)=>``).join('')}
${t(x)}
${esc(j.name)}
${esc(j.device_id)}
${t(j.schedule.type)}
${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}
${t(j.enabled?'enabled':'disabled')}${fmt(j.next_run)}${fmt(j.last_run)}${esc(j.last_result)}
`:`
${t('noTasks')}
`}
`;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()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;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=`

${t('history')}

${buttons('history-refresh','refresh')}
${ops.some(x=>x.status==='UNKNOWN')?`
${t('unknown')}
`:''}
${ops.length?`${['created','action','status','result','client','requestId'].map(x=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${fmt(o.created_at)}${esc(o.action)}
${esc(o.device_id)}
${t(o.status)}${o.status==='UNKNOWN'?`
`:''}
${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}${esc(o.client_id)}${esc(o.request_id)}
${esc(o.operation_id)}
`:`
${t('noHistory')}
`}
`;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(`

${t('reconcile')}

${t('current')}: ${esc(r.current_image_id)}

${t('result')}: ${esc(r.expected_image_id)}

${t('unknown')}

`);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()+`

${t('settings')}

${t('tokenHint')}

${buttons('logout','logout')}

${t('auth')}

${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)}

${t('refreshInterval')}

${t('apiDocs')} →

GET /openapi.json · /api/v1

`;$('#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',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} }; 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(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};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(`

${t('playlists')}

`);$('#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();});} +async 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(`

${t(job?'edit':'newJob')}

`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=``;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};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();});await jobSourceEditor(j);} +async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`

${t('playlists')}

`);$('#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();});await playlistImagePicker(p);} $('#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();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`Pigway Frame Flow · ${t('footer')}v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}`).catch(()=>{}); setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000); diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js new file mode 100644 index 0000000..6c237b8 --- /dev/null +++ b/pigway_frame_flow/web/features.js @@ -0,0 +1,63 @@ +'use strict'; +// Page features share the same authenticated API and display operation pipeline. +async function currentPanel(container){ + const content=await api('/api/v1/devices/'+state.device+'/current-content'); + state.currentDisplay=content; + const known=content.source_known&&content.source?.type==='template'; + container.innerHTML=`

${t('currentContent')}

#${esc(content.image_id??'—')} · ${t(content.synced?'synced':'syncing')}

${known?`${t('currentTemplate')}: ${esc(content.source.template)}`:content.source_known?t('sourceKnown'):t('sourceUnknown')}

${known?buttons('edit-current-template','editCurrent'):''}${content.overrides.length?`
${t('temporaryDisplay')}${content.overrides.map(x=>`

#${esc(x.image_id)} · ${t('priority')}: ${x.priority} · ${fmt(x.expires_at)}

`).join('')}
`:''}`; + bind('edit-current-template',()=>editTemplateSource(content.source)); + return content; +} +function editTemplateSource(source){state.selectedTemplate=source.template;state.pendingTemplateData=structuredClone(source);return navigate('templates');} +async function enhanceDashboard(){ + const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel); + const current=await currentPanel(panel);if(epoch!==state.epoch)return; + if(current.preview_url){frame.innerHTML=`${t('currentContent')}${t('currentContent')} #${current.image_id}`;await blobImage($('#current-frame-image'),current.preview_url);} + const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('weather')}

${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}

${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}

`;$('#main').append(box); +} +async function enhanceTemplates(){ + const panel=document.createElement('section');panel.className='card';$('.template-grid').before(panel);await currentPanel(panel);if(!panel.isConnected)return; + const download=document.createElement('button');download.id='download-template';download.textContent=t('download');$('#display-template').after(download); + download.onclick=busy(async()=>{if(!state.templatePreview){toast(t('previewHint'));return;}await downloadImage('/api/v1/assets/'+state.templatePreview,'frame-flow-template.jpg');}); + const photo=$('#field-photo_id');if(photo){const file=document.createElement('input');file.type='file';file.id='template-photo-upload';file.accept='image/jpeg,image/png,image/webp';file.setAttribute('aria-label',t('selectFile'));photo.after(file);file.onchange=busy(async()=>{if(!file.files[0])return;const body=new FormData();body.append('file',file.files[0]);body.append('fit','cover');body.append('profile','color');const asset=await api('/api/v1/preview',{method:'POST',body});const accepted=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'image',asset_id:asset.asset_id},headers:{'Idempotency-Key':'photo-'+clientUUID()}});await waitOperation(accepted.operation_id);const option=document.createElement('option');option.value=asset.asset_id;option.textContent=asset.name;photo.append(option);photo.value=asset.asset_id;photo.oninput();toast(t('done'));});} +} +async function downloadImage(path,name){const blob=await api(path,{binary:true});const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);} +function displayDialog(content){ + if(!liveWriteAllowed()){toast(t('readOnlyWarning'));return;} + showModal(`

${t('displayOptions')}

`); + $('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');}); +} +function enhanceGallery(){ + const grid=$('.gallery');if(!grid)return; + const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`
`;grid.before(controls); + const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));}; + cards.forEach((card,i)=>{const item=state.gallery[i];if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}}); +} +async function weatherSettings(){ + const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('weatherSettings')}

${['latitude','longitude'].map(k=>`
`).join('')}
`;$('#main').append(box);$('#weather-provider').value=weather.config.provider; + function result(w){$('#weather-result').innerHTML=`

${esc(w.current.temperature)} · ${esc(w.current.summary)}

${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}

${t('forecast')}

${w.forecast.map(x=>`

${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%

`).join('')}`;} + result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));}); +} +async function enhanceHistory(){ + const rows=[...document.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');row.lastElementChild.append(button);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`

${t('details')}

${t(current.status)} · ${esc(current.action)}

${esc(current.error?.message||'')}

${esc(JSON.stringify({operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}
${current.result?.url?'Preview':''}`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});} +async function enhanceAutomation(){ + const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(job.source.type==='template'){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`

${t('jobPreview')}

Preview`);await blobImage($('#job-preview-image'),op.result.url);});}}); +} +async function jobSourceEditor(job){ + const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`${t('advanced')}`;raw.parentElement.before(advanced);advanced.append(raw.parentElement); + const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[];try{[images,assets]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets')]);}catch(e){toast(e.message);}if(!raw.isConnected)return; + box.innerHTML=`
`; + $('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove(); + function update(){raw.value=JSON.stringify(source,null,2);} + function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;} + if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`
`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`
`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();} + else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`
`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};} + else{panel.innerHTML=`
`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}} + $('#job-content-type').onchange=()=>{source={type:$('#job-content-type').value};update();fields();};raw.oninput=()=>{try{source=JSON.parse(raw.value);raw.setCustomValidity('');$('#job-content-type').value=source.type;fields();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};fields(); +} +async function playlistImagePicker(playlist){ + const raw=$('#playlist-images');const box=document.createElement('div');raw.parentElement.after(box);box.innerHTML=`

${t('imageChoice')}

${t('loading')}
`;const images=await api('/api/v1/devices/'+state.device+'/images');if(!box.isConnected)return;const chosen=new Set(playlist.image_ids); + $('#playlist-image-choices').innerHTML=images.map(image=>``).join('');box.querySelectorAll('[data-playlist-image]').forEach(el=>el.onchange=()=>{const id=Number(el.dataset.playlistImage);if(el.checked)chosen.add(id);else chosen.delete(id);raw.value=[...chosen].join(',');});raw.oninput=()=>{chosen.clear();raw.value.split(',').map(Number).filter(x=>x>0).forEach(x=>chosen.add(x));box.querySelectorAll('[data-playlist-image]').forEach(el=>el.checked=chosen.has(Number(el.dataset.playlistImage)));}; +} +async function enhanceSettings(){await weatherSettings();const config=await api('/api/v1/settings/image-url');const box=document.createElement('section');box.className='card';box.innerHTML=`

${t('urlSettings')}

${t('urlHint')}

`;$('#main').append(box);$('#image-host-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/image-url',{method:'PUT',body:{allowed_hosts:$('#image-hosts').value.split('\n').map(x=>x.trim()).filter(Boolean)}});toast(t('done'));});} +function enhanceUpload(){const box=document.createElement('div');box.innerHTML=`

${t('uploadURL')}

${t('urlHint')}

${buttons('preview-url','uploadURL')}`;$('#make-preview').parentElement.append(box);bind('preview-url',async()=>{if(!$('#upload-url').checkValidity())return;const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image_url',url:$('#upload-url').value,fit:$('#fit').value,profile:$('#profile').value});const op=await waitOperation(id);state.preview={asset_id:op.result.asset_id,url:op.result.url};$('#preview-box').className='';$('#preview-box').innerHTML='Preview';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});} diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 26bd8c0..3e51ae6 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -5,3 +5,9 @@ en:{INVALID_REQUEST:'Invalid request; check your input',UNAUTHORIZED:'Missing or Object.assign(window.MESSAGES.zh,{username:'账号',password:'密码',connect:'登录 Frame Flow',UNAUTHORIZED:'账号或密码错误,或登录已过期',tokenHint:'使用账号和密码登录。勾选记住登录后可保持 30 天;共享设备请退出登录。',logout:'退出登录',noToken:'请登录管理画框',formEditor:'表单编辑',advanced:'高级 JSON',resetTemplate:'恢复默认模板',resetConfirm:'清除已保存的模板配置,恢复内置默认值?',eventsHelp:'每行一个事项',photo:'家庭照片',noPhoto:'使用照片占位区域',changePassword:'修改密码',currentPassword:'当前密码',newPassword:'新密码(至少 8 位)',passwordChanged:'密码已修改,请重新登录',saveTemplate:'保存模板设置'}); Object.assign(window.MESSAGES.en,{username:'Username',password:'Password',connect:'Sign in to Frame Flow',UNAUTHORIZED:'Invalid credentials or expired session',tokenHint:'Sign in with your account. Remember me keeps the session for 30 days; sign out on shared devices.',logout:'Sign out',noToken:'Sign in to manage frames',formEditor:'Form editor',advanced:'Advanced JSON',resetTemplate:'Reset template',resetConfirm:'Remove saved settings and restore built-in defaults?',eventsHelp:'One event per line',photo:'Family photo',noPhoto:'Use photo placeholder',changePassword:'Change password',currentPassword:'Current password',newPassword:'New password (at least 8 characters)',passwordChanged:'Password changed. Sign in again.',saveTemplate:'Save template settings'}); + +Object.assign(window.MESSAGES.zh,{currentContent:'当前显示内容',currentTemplate:'当前模板',sourceUnknown:'来源未知:这张图片未通过 Frame Flow 生成,设备 API 不提供模板数据。',editCurrent:'读取当前模板并编辑',sourceKnown:'Frame Flow 已记录来源',weatherSettings:'天气来源',location:'位置名称',latitude:'纬度',longitude:'经度',weatherPreview:'读取天气',forecast:'未来三天天气',weatherUnavailable:'天气暂不可用',weatherAttribution:'天气来源:Open-Meteo(CC BY 4.0)',searchImages:'搜索文件名或图片 ID',sortNewest:'最新优先',sortOldest:'最早优先',sortName:'按名称',download:'下载图片',continueEdit:'继续编辑模板',details:'查看详情',runNow:'立即执行',runConfirm:'现在执行此任务将改变画框显示,是否继续?',jobPreview:'预览任务',temporaryDisplay:'临时显示',displayOptions:'显示选项',sourceType:'内容类型',templateFields:'模板数据',imageChoice:'选择图片',imageURL:'图片 URL',restoreSource:'恢复上一张',weatherMock:'天气演示',weatherReal:'实时天气',stale:'数据过期',apply:'应用'}); +Object.assign(window.MESSAGES.en,{currentContent:'Currently displayed content',currentTemplate:'Current template',sourceUnknown:'Unknown source: this image was not generated by Frame Flow. The device API provides no template data.',editCurrent:'Load current template for editing',sourceKnown:'Source recorded by Frame Flow',weatherSettings:'Weather provider',location:'Location name',latitude:'Latitude',longitude:'Longitude',weatherPreview:'Read weather',forecast:'Three-day forecast',weatherUnavailable:'Weather unavailable',weatherAttribution:'Weather: Open-Meteo (CC BY 4.0)',searchImages:'Search filename or image ID',sortNewest:'Newest first',sortOldest:'Oldest first',sortName:'By name',download:'Download image',continueEdit:'Edit this template',details:'Details',runNow:'Run now',runConfirm:'Running this task will change the frame display. Continue?',jobPreview:'Preview task',temporaryDisplay:'Temporary display',displayOptions:'Display options',sourceType:'Content type',templateFields:'Template data',imageChoice:'Select image',imageURL:'Image URL',restoreSource:'Restore previous image',weatherMock:'Mock weather',weatherReal:'Live weather',stale:'Stale data',apply:'Apply'}); +window.MESSAGES.zh.templateHint='先生成预览,再选择显示方式。天气来源可在设置中配置。';window.MESSAGES.en.templateHint='Render a preview, then choose display options. Configure weather in Settings.'; + +Object.assign(window.MESSAGES.zh,{allowedHosts:'允许下载图片的域名(每行一个)',urlHint:'只允许 HTTPS 和设置中明确允许的域名,内网地址始终禁止。',uploadURL:'从链接导入',urlSettings:'图片 URL 安全设置'});Object.assign(window.MESSAGES.en,{allowedHosts:'Allowed image hostnames (one per line)',urlHint:'HTTPS and explicitly allowed hosts only. Private network addresses are blocked.',uploadURL:'Import URL',urlSettings:'Image URL security'}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index 609e51a..afc1b9d 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index 63369f7..b2bd537 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -11,6 +11,7 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++; await page.screenshot({path:path.join(shots,'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('#gallery-search').fill('no-such-picture-fixture');assert(await page.locator('.image-card:visible').count()===0);checks++;await page.locator('#gallery-search').fill('');await page.locator('#gallery-sort').selectOption('name');assert(await page.locator('.image-card:visible').count()>0);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="upload"]').click();await page.locator('#file').setInputFiles({name:uploadName+'.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a1OIAAAAASUVORK5CYII=','base64')});checks++; await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++; @@ -18,13 +19,13 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});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('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#render-template').click();await page.locator('#template-image').waitFor();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('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++; + await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-content-type').waitFor();await page.locator('#job-template').selectOption('quote');checks++;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('[data-page="settings"]').click();await page.locator('#theme-select').waitFor();await page.locator('#weather-form').waitFor();assert(await page.locator('#weather-provider').inputValue()==='mock');checks++;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(shots,'dashboard-mobile-zh.png'),fullPage:true}); diff --git a/tests/contract.json b/tests/contract.json index a0443f4..7595d40 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -517,6 +517,20 @@ "500", "502", "503" + ], + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" ] }, "/api/v1/playlists": { @@ -610,5 +624,131 @@ "502", "503" ] + }, + "/api/v1/devices/{device_id}/current-content": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/providers/weather": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/settings/weather": { + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/assets/{asset_id}/source": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/jobs/{job_id}/run": { + "post": [ + "202", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/jobs/{job_id}/preview": { + "post": [ + "202", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/settings/image-url": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] } } diff --git a/tests/test_content_provenance.py b/tests/test_content_provenance.py new file mode 100644 index 0000000..6025ff8 --- /dev/null +++ b/tests/test_content_provenance.py @@ -0,0 +1,87 @@ +from pigway_frame_flow.display.service import DisplayService +from pigway_frame_flow.models import DisplayRequest, TemplateContent + + +def display(service, content): + request = DisplayRequest(content=content) + operation = service.submit( + "display", + request.model_dump(mode="json"), + "living-room", + "admin", + "source-test", + ) + service.process_one() + service.sync() + assert service.operation(operation)["status"] == "COMPLETED" + return operation + + +def test_vendor_image_has_unknown_template(service): + current = service.current_content("living-room") + assert current["image_id"] == 1 + assert current["source"] is None and not current["source_known"] + + +def test_template_snapshot_survives_changed_defaults_and_restart(service): + display( + service, + { + "type": "template", + "template": "quote", + "data": {"quote": "Original text"}, + "profile": "grayscale", + }, + ) + first = service.current_content("living-room") + assert first["source"]["data"]["quote"] == "Original text" + assert first["source"]["profile"] == "grayscale" + service.db.execute( + "INSERT OR REPLACE INTO template_config VALUES(?,?)", + ("quote", '{"quote":"Changed defaults"}'), + ) + restarted = DisplayService( + service.db, service.settings, service.registry, service.assets, service.renderer + ) + assert restarted.current_content("living-room")["source"] == first["source"] + + +def test_pre_rendered_asset_retains_template_on_display(service): + asset = service.content_asset( + TemplateContent( + type="template", template="calendar", data={"date": "2026-10-02"} + ) + ) + display(service, {"type": "image", "asset_id": asset["asset_id"]}) + assert service.current_content("living-room")["source"]["template"] == "calendar" + + +def test_external_switch_does_not_claim_old_template(service): + display(service, {"type": "template", "template": "quote", "data": {}}) + service.registry.get("living-room").client.display(1) + service.registry.get("living-room").invalidate() + assert service.current_content("living-room")["source"] is None + + +def test_restore_returns_original_source(service): + display( + service, {"type": "template", "template": "quote", "data": {"quote": "Art"}} + ) + display(service, {"type": "template", "template": "calendar", "data": {}}) + operation = service.submit("restore", {}, "living-room", "admin", "restore-source") + service.process_one() + service.sync() + assert service.operation(operation)["status"] == "COMPLETED" + assert service.current_content("living-room")["source"]["template"] == "quote" + + +def test_asset_source_api(client, service): + asset = service.content_asset( + TemplateContent(type="template", template="quote", data={"quote": "Source"}) + ) + response = client.get("/api/v1/assets/" + asset["asset_id"] + "/source") + assert ( + response.status_code == 200 + and response.json()["source"]["data"]["quote"] == "Source" + ) + assert client.get("/api/v1/devices/living-room/current-content").status_code == 200 diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..d9b7b22 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,127 @@ +from unittest.mock import Mock + +from pigway_frame_flow.models import WeatherConfig +from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider + + +def transport(): + response = Mock() + response.json.return_value = { + "current": { + "temperature_2m": 24, + "weather_code": 3, + "time": "2026-10-02T10:00", + }, + "daily": { + "time": ["2026-10-02"], + "weather_code": [61], + "temperature_2m_min": [20], + "temperature_2m_max": [26], + "precipitation_probability_max": [80], + }, + } + return Mock(return_value=response) + + +def test_current_forecast_and_cache(service): + get = transport() + provider = ConfigurableWeatherProvider(service.db, get) + provider.configure(WeatherConfig(provider="open-meteo")) + assert provider.get_current()["temperature"] == "24°C" + assert provider.get_forecast()[0]["rain_probability"] == 80 + assert get.call_count == 1 + assert get.call_args.kwargs["timeout"] == 8 + assert not get.call_args.kwargs["allow_redirects"] + + +def test_weather_stale_and_error_backoff(service): + get = transport() + provider = ConfigurableWeatherProvider(service.db, get) + provider.configure(WeatherConfig(provider="open-meteo")) + provider.get_current() + provider.next_fetch = 0 + get.side_effect = TimeoutError("private transport detail") + assert provider.get_current()["stale"] + assert provider.get_current()["temperature"] == "24°C" + assert get.call_count == 2 + assert "private" not in str(provider.get_current()) + + +def test_unavailable_never_masquerades_as_mock(service): + get = Mock(side_effect=TimeoutError("secret")) + provider = ConfigurableWeatherProvider(service.db, get) + provider.configure(WeatherConfig(provider="open-meteo")) + current = provider.get_current() + assert not current["mock"] and not current["available"] and current["stale"] + assert "secret" not in str(current) + + +def test_provider_settings_persist_and_validate(client, service): + assert client.get("/api/v1/providers/weather").json()["current"]["mock"] + assert ( + client.put("/api/v1/settings/weather", json={"latitude": 100}).status_code + == 422 + ) + assert ( + client.put( + "/api/v1/settings/weather", + json={ + "provider": "mock", + "location": "Test town", + "latitude": 30, + "longitude": 121, + }, + ).status_code + == 200 + ) + assert ConfigurableWeatherProvider(service.db).config().location == "Test town" + + +def test_manual_job_run_and_preview_are_distinct(client, service): + job = client.post( + "/api/v1/jobs", + json={ + "name": "Preview", + "enabled": False, + "schedule": {"type": "daily", "time": "07:00"}, + "source": {"type": "template", "template": "quote", "data": {}}, + }, + ).json() + assert client.get("/api/v1/jobs/" + job["id"]).status_code == 200 + preview = client.post("/api/v1/jobs/" + job["id"] + "/preview").json() + service.process_one() + assert service.operation(preview["operation_id"])["status"] == "COMPLETED" + assert service.registry.get("living-room").client.uploads == 0 + service.settings.mock = False + assert client.post("/api/v1/jobs/" + job["id"] + "/run").status_code == 403 + service.settings.mock = True + first = client.post( + "/api/v1/jobs/" + job["id"] + "/run", headers={"Idempotency-Key": "manual-test"} + ) + second = client.post( + "/api/v1/jobs/" + job["id"] + "/run", headers={"Idempotency-Key": "manual-test"} + ) + assert first.status_code == 202 and first.json() == second.json() + + +def test_image_url_allowlist_is_persistent_and_restrictive(client, service): + assert client.get("/api/v1/settings/image-url").json()["allowed_hosts"] == [] + assert ( + client.put( + "/api/v1/settings/image-url", + json={"allowed_hosts": ["https://example.com"]}, + ).status_code + == 422 + ) + assert ( + client.put( + "/api/v1/settings/image-url", json={"allowed_hosts": ["*.example.com"]} + ).status_code + == 422 + ) + assert client.put( + "/api/v1/settings/image-url", + json={"allowed_hosts": ["EXAMPLE.com", "example.com"]}, + ).json()["allowed_hosts"] == ["example.com"] + assert service.settings.image_url_hosts == ["example.com"] + assert service.db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")