Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation

This commit is contained in:
Pigway
2026-10-02 00:03:48 +08:00
commit 6652590b84
79 changed files with 13773 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
import json
import logging
import random
import threading
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from zoneinfo import ZoneInfo
from croniter import croniter
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import (
DisplayRequest,
ImageContent,
JobInput,
Options,
PlaylistInput,
RestoreContent,
)
from pigway_frame_flow.storage.database import encode, now_iso
def next_run(schedule, after=None):
after = after or datetime.now(UTC)
if schedule.type in ("date", "temporary"):
if not schedule.at or schedule.at.tzinfo is None:
raise FlowError(E.INVALID_REQUEST, "One-time date must include timezone")
return schedule.at.astimezone(UTC).isoformat() if schedule.at > after else None
if schedule.type == "cron":
expression = schedule.cron or ""
if len(expression.split()) != 5 or not croniter.is_valid(expression):
raise FlowError(E.INVALID_REQUEST, "Cron requires five valid fields")
else:
if not schedule.time:
raise FlowError(E.INVALID_REQUEST, "Daily schedule requires HH:MM")
hour, minute = schedule.time.split(":")
day = (
"1-5"
if schedule.type == "weekdays"
else ("6,0" if schedule.type == "weekends" else "*")
)
expression = f"{minute} {hour} * * {day}"
return (
croniter(expression, after.astimezone(ZoneInfo("Asia/Shanghai")))
.get_next(datetime)
.astimezone(UTC)
.isoformat()
)
class Scheduler:
def __init__(self, service):
self.service, self.db = service, service.db
self.stop_event = threading.Event()
self.thread = None
self.tick_lock = threading.Lock()
def jobs(self):
rows = [
dict(
json.loads(x["payload"]),
id=x["id"],
created_at=x["created_at"],
updated_at=x["updated_at"],
last_run=x["last_run"],
last_result=x["last_result"],
next_run=x["next_run"],
)
for x in self.db.all("SELECT * FROM jobs ORDER BY created_at DESC")
]
for row in rows:
if row["last_result"]:
operation = self.db.one(
"SELECT status FROM operations WHERE id=?", (row["last_result"],)
)
if operation:
row["last_result"] = operation["status"]
return rows
def save_job(self, job, id=None):
self.service.registry.get(job.device_id)
if job.source.type != "restore":
self.service.validate(
DisplayRequest(device_id=job.device_id, content=job.source),
writing=False,
)
upcoming = next_run(job.schedule)
if job.enabled and upcoming is None:
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
id = id or uuid4().hex
now = now_iso()
self.db.execute(
"INSERT INTO jobs(id,payload,enabled,next_run,created_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,enabled=excluded.enabled,next_run=excluded.next_run,updated_at=excluded.updated_at",
(
id,
encode(self.service.settings.sanitize(job.model_dump(mode="json"))),
int(job.enabled),
upcoming,
now,
now,
),
)
return next(x for x in self.jobs() if x["id"] == id)
def playlists(self):
return [
dict(
json.loads(x["payload"]),
id=x["id"],
position=x["position"],
next_run=x["next_run"],
)
for x in self.db.all("SELECT * FROM playlists")
]
def save_playlist(self, playlist, id=None):
self.service.registry.get(playlist.device_id)
for existing in self.playlists():
if (
existing["enabled"]
and playlist.enabled
and existing["device_id"] == playlist.device_id
and existing["id"] != id
):
raise FlowError(
E.CONFLICT, "Only one Frame Flow playlist may run per device", 409
)
id = id or uuid4().hex
self.db.execute(
"INSERT INTO playlists VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,next_run=excluded.next_run",
(
id,
encode(playlist.model_dump()),
-1,
now_iso() if playlist.enabled else None,
),
)
return next(x for x in self.playlists() if x["id"] == id)
def tick(self, current=None):
current = current or datetime.now(UTC)
with self.tick_lock:
for row in self.db.all(
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
(current.isoformat(),),
):
job = JobInput.model_validate_json(row["payload"])
slot = row["next_run"]
if self.db.one(
"SELECT * FROM job_runs WHERE job_id=? AND scheduled_at=?",
(row["id"], slot),
):
continue
# Skip overdue tasks (>5 minutes): avoid an unexpected artwork
# overwrite after downtime. One-time tasks are retired too.
late = (current - datetime.fromisoformat(slot)).total_seconds() > 300
operation_id = None
if not late:
action = "restore" if job.source.type == "restore" else "display"
payload = (
{}
if isinstance(job.source, RestoreContent)
else DisplayRequest(
device_id=job.device_id,
content=job.source,
options=Options(
duration=job.display_duration,
restore_previous=job.restore_previous,
priority=job.priority,
),
).model_dump(mode="json")
)
operation_id = self.service.submit(
action,
payload,
job.device_id,
"scheduler",
"schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot,
)
following = next_run(job.schedule, current)
enabled = bool(following)
updated = job.model_copy(update={"enabled": enabled}).model_dump(
mode="json"
)
with self.db.transaction() as tx:
tx.execute(
"INSERT OR IGNORE INTO job_runs VALUES(?,?,?)",
(row["id"], slot, operation_id),
)
tx.execute(
"UPDATE jobs SET payload=?,enabled=?,next_run=?,last_run=?,last_result=? WHERE id=?",
(
encode(updated),
int(enabled),
following,
now_iso(),
"SKIPPED_OVERDUE" if late else operation_id,
row["id"],
),
)
for row in self.db.all(
"SELECT * FROM playlists WHERE next_run<=?", (current.isoformat(),)
):
p = PlaylistInput.model_validate_json(row["payload"])
if (
not p.enabled
or self.service.top(p.device_id)
or self.service.blocked(p.device_id)
):
continue
if (
not self.service.settings.mock
and not self.service.settings.write_enabled
):
continue
pos = (
random.randrange(len(p.image_ids))
if p.mode == "random"
else (row["position"] + 1) % len(p.image_ids)
)
request = DisplayRequest(
device_id=p.device_id,
content=ImageContent(type="image", image_id=p.image_ids[pos]),
options=Options(priority=0),
)
self.service.submit(
"display",
request.model_dump(mode="json"),
p.device_id,
"scheduler",
"playlist-" + uuid4().hex,
"playlist-" + row["id"] + "-" + row["next_run"],
)
following = (
current + timedelta(minutes=p.interval_minutes)
).isoformat()
self.db.execute(
"UPDATE playlists SET position=?,next_run=? WHERE id=?",
(pos, following, row["id"]),
)
def start(self):
self.thread = threading.Thread(
target=self.run, name="frame-flow-worker", daemon=True
)
self.thread.start()
def run(self):
while not self.stop_event.is_set():
try:
self.tick()
self.service.expire()
processed = self.service.process_one()
self.service.sync()
except Exception:
logging.getLogger("frame-flow").error(
"Background cycle failed; details suppressed"
)
processed = False
self.stop_event.wait(1 if processed else 5)
def stop(self):
self.stop_event.set()
if self.thread:
self.thread.join(timeout=45)
def healthy(self):
return bool(
self.thread and self.thread.is_alive() and not self.stop_event.is_set()
)