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
+347
View File
@@ -0,0 +1,347 @@
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pigway_frame_flow.errors import ErrorCode
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class Fit(StrEnum):
contain = "contain"
cover = "cover"
smart = "smart"
class Profile(StrEnum):
color = "color"
grayscale = "grayscale"
class ImageContent(StrictModel):
type: Literal["image"]
image_id: int | None = Field(default=None, gt=0)
image_name: str | None = Field(default=None, min_length=1, max_length=120)
asset_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
base64: str | None = Field(default=None, max_length=28_000_000)
name: str = Field(default="image.jpg", min_length=1, max_length=120)
fit: Fit = Fit.contain
profile: Profile = Profile.color
@model_validator(mode="after")
def one_source(self):
if (
sum(
x is not None
for x in [self.image_id, self.image_name, self.asset_id, self.base64]
)
!= 1
):
raise ValueError("Specify exactly one image_id, asset_id, or base64")
return self
class URLContent(StrictModel):
type: Literal["image_url"]
url: str = Field(max_length=2048)
name: str = Field(default="image.jpg", max_length=120)
fit: Fit = Fit.contain
profile: Profile = Profile.color
class TemplateContent(StrictModel):
type: Literal["template"]
template: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
data: dict[str, Any] = Field(default_factory=dict)
profile: Profile = Profile.color
class FutureContent(StrictModel):
type: Literal["html", "markdown"]
data: str = ""
Content = Annotated[
ImageContent | URLContent | TemplateContent | FutureContent,
Field(discriminator="type"),
]
class Options(StrictModel):
duration: int = Field(default=0, ge=0, le=604800)
restore_previous: bool = False
priority: int = Field(default=50, ge=0, le=100)
@model_validator(mode="after")
def restoration(self):
if self.restore_previous and self.duration == 0:
raise ValueError("restore_previous requires a positive duration")
return self
class Source(StrictModel):
client: str | None = Field(default=None, max_length=80)
request_id: str | None = Field(default=None, max_length=120)
class DisplayRequest(StrictModel):
device_id: str = "living-room"
content: Content
options: Options = Field(default_factory=Options)
source: Source = Field(default_factory=Source)
class Accepted(BaseModel):
operation_id: str
job_id: str
status: Literal["accepted"] = "accepted"
class OperationStatus(StrEnum):
QUEUED = "QUEUED"
RENDERING = "RENDERING"
UPLOADING = "UPLOADING"
SYNCING = "SYNCING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
UNKNOWN = "UNKNOWN"
class ErrorBody(BaseModel):
code: ErrorCode
message: str
request_id: str
details: dict[str, Any] = Field(default_factory=dict)
class ErrorResponse(BaseModel):
error: ErrorBody
class OperationResult(BaseModel):
image_id: int | None = None
asset_id: str | None = None
rendered_asset_id: str | None = None
url: str | None = None
deferred: bool = False
class Operation(BaseModel):
operation_id: str
status: OperationStatus
device_id: str
created_at: str
updated_at: str
result: OperationResult | None = None
error: ErrorBody | None = None
request_id: str
client_id: str
action: str
class Capabilities(BaseModel):
upload: bool = True
gallery: bool = True
delete: bool = True
switch: bool = True
remote_refresh: bool = True
battery: bool = True
color: bool = True
cloud_upload_without_display: bool = False
class DisplayGeometry(BaseModel):
width: int
height: int
rotation: int
color_mode: Profile = Profile.color
class Power(BaseModel):
battery_percent: int | None
charging: bool
class CurrentContent(BaseModel):
type: Literal["image"] = "image"
id: int | None
cover_id: int | None
synced: bool
class Device(BaseModel):
id: str
name: str
driver: Literal["kokonna"] = "kokonna"
status: Literal["online", "offline"]
model: str
model_code: str
firmware: str | None
display: DisplayGeometry
power: Power
current_content: CurrentContent
last_seen_at: str | None
timezone: str
capabilities: Capabilities = Field(default_factory=Capabilities)
slideshow: dict[str, Any] = Field(default_factory=dict)
storage_used_bytes: int | None = None
model_verified: bool = False
class GalleryImage(BaseModel):
width: int | None = None
height: int | None = None
size_bytes: int | None = None
media_type: str | None = None
image_id: int
name: str
created_at: str | None
current: bool
url: str
class Schedule(StrictModel):
type: Literal["cron", "daily", "weekdays", "weekends", "date", "temporary"]
cron: str | None = None
time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
at: datetime | None = None
class RestoreContent(StrictModel):
type: Literal["restore"]
class JobInput(StrictModel):
enabled: bool = True
device_id: str = "living-room"
name: str = Field(min_length=1, max_length=120)
schedule: Schedule
source: Content | RestoreContent
display_duration: int = Field(default=0, ge=0, le=604800)
restore_previous: bool = False
priority: int = Field(default=50, ge=0, le=100)
@model_validator(mode="after")
def validate_options(self):
Options(
duration=self.display_duration,
restore_previous=self.restore_previous,
priority=self.priority,
)
return self
class Job(JobInput):
id: str
created_at: str
updated_at: str
last_run: str | None = None
last_result: str | None = None
next_run: str | None = None
class TemplateInfo(BaseModel):
id: str
name: str
width: int = 1200
height: int = 1600
fields: list[str]
defaults: dict[str, Any]
class TemplateConfig(StrictModel):
data: dict[str, Any]
class History(BaseModel):
id: int
client_id: str
request_id: str
operation_id: str | None
device_id: str
action: str
result: str
timestamp: str
class SettingsView(BaseModel):
version: str
mock: bool
write_enabled: bool
api_key_configured: bool
api_token_configured: bool
timezone: str
device_id: str
weather_provider: str
class Health(BaseModel):
status: str
version: str = "0.1.0"
class Readiness(BaseModel):
status: str
checks: dict[str, bool]
class Asset(BaseModel):
asset_id: str
name: str
url: str
created_at: str
class DeleteRequest(StrictModel):
confirm_current: bool = False
class RestoreRequest(StrictModel):
device_id: str = "living-room"
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.0"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
class PlaylistInput(StrictModel):
device_id: str = "living-room"
name: str = Field(min_length=1, max_length=120)
image_ids: list[int] = Field(min_length=1, max_length=100)
interval_minutes: int = Field(default=60, ge=5, le=10080)
mode: Literal["queue", "random"] = "queue"
enabled: bool = False
@model_validator(mode="after")
def ids(self):
if any(x <= 0 for x in self.image_ids) or len(set(self.image_ids)) != len(
self.image_ids
):
raise ValueError("Image IDs must be positive and unique")
return self
class Playlist(PlaylistInput):
id: str
position: int
next_run: str | None
class ReconcileRequest(StrictModel):
acknowledge: bool = False
class Reconciliation(BaseModel):
operation_id: str
current_image_id: int | None
expected_image_id: int | None
cloud_images: list[GalleryImage]
resolved: bool