Files
pigway-frame-flow/pigway_frame_flow/models.py
T

523 lines
13 KiB
Python
Raw Normal View History

from datetime import datetime
from enum import StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, StrictBool, 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 PhotoSourceContent(StrictModel):
type: Literal["photo_source"]
source_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
item_id: str | None = Field(default=None, max_length=2048)
fit: Fit = Fit.contain
profile: Profile = Profile.color
class AutoCollageContent(StrictModel):
type: Literal["auto_collage"]
source_id: str | None = Field(default=None, pattern=r"^[a-z0-9-]{1,60}$")
count: int = Field(default=4, ge=1, le=12)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
orientation: Literal["auto", "portrait", "landscape"] = "auto"
style: Literal["grid", "hero", "poster"] = "grid"
use_ai: bool = True
prompt: str = Field(default="", max_length=1500)
profile: Profile = Profile.color
class ProviderContent(StrictModel):
type: Literal["provider"]
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
template: str = Field(default="daily-dashboard", 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
| PhotoSourceContent
| AutoCollageContent
| ProviderContent
| FutureContent,
Field(discriminator="type"),
]
class Options(StrictModel):
cleanup_unstarred: bool = False
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)
@model_validator(mode="after")
def retention_source(self):
if self.options.cleanup_unstarred and not (
isinstance(self.content, PhotoSourceContent)
and self.content.item_id is None
):
raise ValueError("Retention requires random photo-source content")
return self
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_name: str | None = None
preview_url: str | None = None
cover_id: int | None = None
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
image_id: int | None = None
asset_id: str | None = None
rendered_asset_id: str | None = None
url: str | None = None
deferred: bool = False
class RecoveryState(BaseModel):
state: Literal["checking", "manual_review", "resolved"]
reason: str
first_checked_at: str
last_checked_at: str
checks: int
requires_manual_review: bool
class Operation(BaseModel):
recovery: RecoveryState | None = None
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: str = "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
can_restore: bool = False
class GalleryImage(BaseModel):
starred: bool = False
expires_at: str | None = None
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", "interval"
]
interval_minutes: int | None = Field(default=None, ge=1, le=10080)
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):
cleanup_unstarred: bool = False
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):
if self.cleanup_unstarred and not (
isinstance(self.source, PhotoSourceContent) and self.source.item_id is None
):
raise ValueError("Retention requires a random photo-source task")
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 GalleryDestination(BaseModel):
id: str
adapter: str
name: str
storage: Literal["cloud", "local"]
connected: bool = False
supports_upload: bool = False
class DeviceIdentity(BaseModel):
id: str
name: str
class SettingsView(BaseModel):
storage_name: str = "NAS"
gallery_destinations: list[GalleryDestination] = Field(default_factory=list)
device_catalog: list[DeviceIdentity] = Field(default_factory=list)
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.29"
class Readiness(BaseModel):
status: str
checks: dict[str, bool]
class Asset(BaseModel):
excluded: bool = False
source_path: str | None = None
starred: bool = False
origin: Literal["manual", "task", "external", "template", "unknown"] = "unknown"
job_id: str | None = None
job_name: str | None = None
source: Literal["uploaded", "generated"] = "uploaded"
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.29"
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
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
class Widget(StrictModel):
id: int
name: str
tags: list[str] = Field(default_factory=list)
arg_keys: list[str] = Field(default_factory=list)
class WidgetState(StrictModel):
source: Literal["live", "snapshot", "mock"]
configured: bool
status: str = "OK"
observed_at: str | None = None
catalog: list[Widget]
current: Widget | None = None
current_verified: bool = False
writable: Literal[False] = False
class ImageExclusion(StrictModel):
excluded: StrictBool
class PhotoPreference(StrictModel):
item_id: str = Field(min_length=1, max_length=4096)
starred: StrictBool | None = None
excluded: StrictBool | None = None
class ImageStar(StrictModel):
starred: StrictBool
class AssetBatch(StrictModel):
asset_ids: list[str] = Field(min_length=1, max_length=100)
action: Literal["delete", "star", "unstar", "exclude", "include"]