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')} ${t('currentPassword')}
${t('newPassword')}
${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')}
${[['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])=>`
`).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()+`${t('cloud')} ${t('local')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${items.length?`${items.map((x,i)=>`
${esc(x.name)} ${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))} ${x.current?`${t('current')}
`:''}${t('display')} ${t('original')} ${t('delete')}
`).join('')}
`:`${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')} ${t('name')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);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)} ${buttons('modal-cancel','cancel')}
`);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')} ${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')}
`;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=' ';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')} ${t('profile')} ${t('color')} ${t('grayscale')}
${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}
${t('preview')} ${t('previewHint')}
${t('display')} `;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=' ';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()+`${t('cloud')} ${t('local')}
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`${t('uploadNotice')}
`:''}${items.length?`${items.map((x,i)=>`
${esc(x.name)} ${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))} ${x.current?`${t('current')}
`:''}${t('display')} ${t('original')} ${t('delete')}
`).join('')}
`:`${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')} ${t('name')}
${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}
`);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)} ${buttons('modal-cancel','cancel')}
`);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')} ${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')} ${t('previewHint')}
${t('staging')} ${t('display')}
`;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=' ';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')} ${t('profile')} ${t('color')} ${t('grayscale')}
${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}
${t('preview')} ${t('previewHint')}
${t('display')} `;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=' ';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=>`${t(x)} `).join('')} ${jobs.map((j,i)=>`${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('edit')} ${t(j.enabled?'disabled':'enabled')} ${t('remove')}
`).join('')}
`:`
${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)}${playlists.map((p,i)=>`
${t(p.enabled?'enabled':'disabled')} ${esc(p.name)} ${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}
${esc(p.image_ids.join(' → '))}
${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'} ${t('edit')} ${t('remove')}
`).join('')||`
${t('noTasks')}
`}
`;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=>`${t(x)} `).join('')} ${ops.map((o,i)=>`${fmt(o.created_at)} ${esc(o.action)}${esc(o.device_id)} ${t(o.status)} ${o.status==='UNKNOWN'?`${t('reconcile')}
`:''}${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')} ${esc(o.client_id)} ${esc(o.request_id)} ${esc(o.operation_id)} `).join('')}
`:`
${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')}
${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}
`);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('theme')} ${['system','light','dark'].map(x=>`${t(x==='system'?'systemTheme':x)} `).join('')}
${t('language')} 简体中文 English
${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=`${t('template')} ${t('advanced')} ${state.templates.map(x=>`${esc(x.name)} `).join('')} `;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')} ${t('name')}
${t('imageIds')}
${t('enabled')}${buttons('modal-cancel','cancel')}${t('save')}
`);$('#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')} ${t('name')}
${t('source')} (JSON) ${esc(JSON.stringify(j.source,null,2))}
${t('enabled')} ${t('restoreAfter')}${buttons('modal-cancel','cancel')}${t('save')}
`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`${t('template')} ${t('advanced')} ${state.templates.map(x=>`${esc(x.name)} `).join('')} `;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')} ${t('name')}
${t('imageIds')}
${t('enabled')}${buttons('modal-cancel','cancel')}${t('save')}
`);$('#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')} #${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')} ${t('restoreAfter')}${buttons('modal-cancel','cancel')}${t('display')}
`);
+ $('#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=`${t('searchImages')}
${t('mode')} ${t('sortNewest')} ${t('sortOldest')} ${t('sortName')}
`;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')} Provider ${t('weatherMock')} Open-Meteo
${t('location')}
${t('save')}
`;$('#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?' ':''}${buttons('modal-cancel','cancel')}
`);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')} ${buttons('modal-cancel','cancel')}
`);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=`${t('sourceType')} ${['template','image','image_url','restore'].map(k=>`${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):t('imageURL')} `).join('')}
`;
+ $('#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=`${t('template')} ${state.templates.map(x=>`${esc(x.name)} `).join('')}
${t('profile')} ${t('color')} ${t('grayscale')}
`;$('#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=>`${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)} ${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[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=`${t('imageChoice')} — ${images.map(i=>`${esc(i.name)} · #${i.image_id} `).join('')}${assets.map(a=>`${esc(a.name)} · LOCAL `).join('')}
`;$('#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=`${t('imageURL')}
`;$('#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=>` ${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''} `).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('allowedHosts')} ${esc(config.allowed_hosts.join('\n'))} ${t('urlHint')}
${t('save')} `;$('#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')} ${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=' ';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
-
+