feat: content provenance and complete frame management workflows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
],
|
||||
"size": 42,
|
||||
"kind": "photo"
|
||||
},
|
||||
{
|
||||
"kind": "text",
|
||||
"field": "weather_attribution",
|
||||
"box": [
|
||||
72,
|
||||
1448,
|
||||
1056,
|
||||
45
|
||||
],
|
||||
"size": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
],
|
||||
"size": 44,
|
||||
"kind": "text"
|
||||
},
|
||||
{
|
||||
"kind": "text",
|
||||
"field": "weather_attribution",
|
||||
"box": [
|
||||
72,
|
||||
1448,
|
||||
1056,
|
||||
45
|
||||
],
|
||||
"size": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function notice(){if(!state.settings)return '';return `<div class="notice ${!sta
|
||||
async function navigate(page=state.page){state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();clearBlobs();state.epoch++;const epoch=state.epoch;
|
||||
if(!state.token){login();return;}
|
||||
$('#main').innerHTML='<div class="spinner"></div>';
|
||||
try{await views[state.page](epoch);guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;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=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
||||
}
|
||||
function login(){ $('#main').innerHTML=`<div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="username">${t('username')}</label><input id="username" required autocomplete="username"></div><div class="field"><label for="password">${t('password')}</label><input id="password" type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked>${t('rememberLogin')}</label><button class="primary" type="submit">${t('connect')}</button></form></div>`;$('#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=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;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?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#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()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small>${x.current?`<div><span class="badge">${t('current')}</span></div>`:''}<div class="actions"><button class="primary" data-show="${i}">${t('display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{await accepted('/api/v1/display',{device_id:state.device,content:{type:'image',image_name:$('#image-name').value}});$('#modal').close();});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];return accepted('/api/v1/display',{device_id:state.device,content:state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id}});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
|
||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=false;});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',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()+`<div class="notice">${t('templateHint')}</div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${['☀','▦','◒','▣','❝','☁'][i]}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('previewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};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='<img id="template-image" class="preview" alt="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()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small>${x.current?`<div><span class="badge">${t('current')}</span></div>`:''}<div class="actions"><button class="primary" data-show="${i}">${t('display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{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(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
|
||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!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()+`<div class="notice">${t('templateHint')}</div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${['☀','▦','◒','▣','❝','☁'][i]}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('previewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};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='<img id="template-image" class="preview" alt="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()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',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(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;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(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('mode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});}
|
||||
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(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;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(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('mode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});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=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||
|
||||
@@ -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=`<h3>${t('currentContent')}</h3><p>#${esc(content.image_id??'—')} · ${t(content.synced?'synced':'syncing')}</p><p>${known?`${t('currentTemplate')}: <strong>${esc(content.source.template)}</strong>`:content.source_known?t('sourceKnown'):t('sourceUnknown')}</p>${known?buttons('edit-current-template','editCurrent'):''}${content.overrides.length?`<div class="notice"><strong>${t('temporaryDisplay')}</strong>${content.overrides.map(x=>`<p>#${esc(x.image_id)} · ${t('priority')}: ${x.priority} · ${fmt(x.expires_at)}</p>`).join('')}</div>`:''}`;
|
||||
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=`<img id="current-frame-image" alt="${t('currentContent')}" style="width:100%;height:100%;object-fit:contain"><span class="frame-label">${t('currentContent')} #${current.image_id}</span>`;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=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#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(`<h2>${t('displayOptions')}</h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
|
||||
$('#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=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;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=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">Provider</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
||||
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).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(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(current.action)}</p><p>${esc(current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${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))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);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(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||
}
|
||||
async function jobSourceEditor(job){
|
||||
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;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=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
|
||||
$('#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=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#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=>`<div class="field"><label for="job-field-${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)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).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=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#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=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#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=`<p>${t('imageChoice')}</p><div id="playlist-image-choices">${t('loading')}</div>`;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=>`<label class="check-row"><input type="checkbox" data-playlist-image="${image.image_id}" ${chosen.has(image.image_id)?'checked':''}>${esc(image.name)} · #${image.image_id}${image.current?' · '+t('current'):''}</label>`).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=`<h3>${t('urlSettings')}</h3><form id="image-host-form"><div class="field"><label for="image-hosts">${t('allowedHosts')}</label><textarea id="image-hosts" rows="3">${esc(config.allowed_hosts.join('\n'))}</textarea><p class="hint">${t('urlHint')}</p></div><button type="submit">${t('save')}</button></form>`;$('#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=`<h3>${t('uploadURL')}</h3><div class="field"><label for="upload-url">${t('imageURL')}</label><input id="upload-url" type="url" placeholder="https://…"><p class="hint">${t('urlHint')}</p></div>${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='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});}
|
||||
@@ -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'});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><span class="live-dot"></span><span id="connection">—</span><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/app.js"></script></body></html>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><span class="live-dot"></span><span id="connection">—</span><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/app.js"></script></body></html>
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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'")
|
||||
Reference in New Issue
Block a user