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
+144
View File
@@ -0,0 +1,144 @@
"""Hardened adapter over the MIT-0 guooscar 1.2.0 device/upload methods.
No UI, scheduler, renderer, environment lookup, or constructor cloud calls.
Never retry a write; a timeout may have occurred after cloud acceptance.
"""
import threading
import time
from collections import deque
from urllib.parse import quote
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from vendor import guooscar_protocol
BASE_URL = "https://api.kokonna.art/openapi"
class SafeTransport:
def __init__(self, key, session=None):
self.key = key
self.session = session or requests.Session()
self.calls: deque[float] = deque()
self.lock = threading.Lock()
def request(self, endpoint, payload=None, write=False, binary=False):
if not self.key:
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
with self.lock:
current = time.monotonic()
while self.calls and current - self.calls[0] >= 60:
self.calls.popleft()
# Reserve capacity beneath the official 20/minute ceiling.
if len(self.calls) >= 18:
raise FlowError(E.RATE_LIMITED, "Device request budget exhausted", 429)
self.calls.append(current)
url = BASE_URL + endpoint
try:
response = self.session.request(
"GET" if binary else "POST",
url,
headers={}
if binary
else {
"Authorization": "Bearer " + self.key,
"Content-Type": "application/json",
},
json=None if binary else payload or {},
timeout=(5, 30),
allow_redirects=False,
)
except requests.RequestException:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
"Cloud write outcome is unknown"
if write
else "Device cloud is unavailable",
502,
) from None
if response.status_code != 200:
code = (
E.RATE_LIMITED
if response.status_code == 429
else (
E.WRITE_RESULT_UNKNOWN
if write and response.status_code >= 500
else E.CLOUD_UNAVAILABLE
)
)
raise FlowError(
code,
"Device cloud request failed",
429 if code == E.RATE_LIMITED else 502,
)
if binary:
if len(response.content) > 20 * 1024 * 1024:
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
return response.content
try:
value = response.json()
if not isinstance(value, dict):
raise ValueError()
# Cloud errors may echo credentials: never return raw messages.
if value.get("success") is False or (
"message" in value and "id" not in value
):
raise ValueError()
return value
except ValueError:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
"Invalid device cloud response",
502,
) from None
class KokonnaClient:
def __init__(self, key, write_enabled=False, session=None):
self.transport = SafeTransport(key, session)
self.write_enabled = write_enabled
def guard(self):
if not self.write_enabled:
raise FlowError(
E.WRITE_DISABLED,
"Real device writes require explicit authorization",
403,
)
def get_device(self):
return guooscar_protocol.device(self.transport)
def list_images(self):
return self.transport.request("/listImages", {})
def upload(self, path, name):
self.guard()
value = guooscar_protocol.upload(self.transport, path.read_bytes(), name)
if not isinstance(value.get("id"), int) or value["id"] <= 0:
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Upload ID not confirmed", 502)
return value["id"]
def display(self, image_id):
self.guard()
return self.transport.request(
"/displayImageById", {"imageId": image_id}, write=True
)
def delete(self, image_id):
self.guard()
return self.transport.request("/deleteImage", {"imageId": image_id}, write=True)
def download(self, image_id):
return self.transport.request(
f"/image/{quote(self.transport.key, safe='')}/{image_id}", binary=True
)
def display_by_name(self, name):
self.guard()
return self.transport.request(
"/displayImageByName", {"imageName": name}, write=True
)
+104
View File
@@ -0,0 +1,104 @@
from pigway_frame_flow.models import (
CurrentContent,
Device,
DisplayGeometry,
GalleryImage,
Power,
)
class KokonnaDriver:
def __init__(self, client, device_id, sanitize=lambda v: v):
self.client, self.device_id, self.sanitize = client, device_id, sanitize
self.cached: Device | None = None
self.cached_at = 0.0
def device(self, refresh=False):
import time
if not refresh and self.cached and time.monotonic() - self.cached_at < 60:
return self.cached
d = self.sanitize(self.client.get_device())
result = Device(
id=self.device_id,
name=d.get("nickname", "KoKonna"),
status="online" if d.get("online") else "offline",
model=d.get("model", "13.3-inch"),
model_code=d.get("modelCode", "B101"),
firmware=d.get("firmware"),
display=DisplayGeometry(
**{
"width": d.get("screenWidth", 1200),
"height": d.get("screenHeight", 1600),
"rotation": d.get("screenRotate", 270),
}
),
power=Power(
**{
"battery_percent": d.get("batteryLevel"),
"charging": bool(d.get("isCharging")),
}
),
current_content=CurrentContent(
**{
"id": d.get("imageId"),
"cover_id": d.get("coverId"),
"synced": bool(d.get("synced")),
}
),
slideshow={
"mode": d.get("switchType"),
"interval_minutes": d.get("switchMinute"),
"members_available": False,
"editable": False,
},
storage_used_bytes=d.get("sdUsedSize"),
model_verified=bool(d.get("model") and d.get("modelCode")),
last_seen_at=d.get("lastHeartbeat"),
timezone=d.get("timezone", "Asia/Shanghai"),
)
self.cached = result
self.cached_at = time.monotonic()
return result
def get_current_image(self):
return self.device().current_content.id
def health(self):
return bool(self.device())
def invalidate(self):
self.cached_at = 0.0
def display_by_name(self, name):
return self.client.display_by_name(name)
def images(self):
data = self.sanitize(self.client.list_images())
return [
GalleryImage(
image_id=d["id"],
width=d.get("width"),
height=d.get("height"),
size_bytes=d.get("size"),
media_type=d.get("type"),
name=d.get("name", "image"),
created_at=d.get("createdAt"),
current=bool(d.get("current")),
url=f"/api/v1/devices/{self.device_id}/images/{d['id']}/file",
)
for d in data.get("list", [])
]
def upload(self, path, name):
return self.client.upload(path, name)
def display(self, image_id):
return self.client.display(image_id)
def delete(self, image_id):
return self.client.delete(image_id)
def download(self, image_id):
return self.client.download(image_id)
+108
View File
@@ -0,0 +1,108 @@
"""Persistent mock implements the documented upload-immediately-activates behavior."""
import json
from PIL import Image
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.storage.database import now_iso
class MockClient:
def __init__(self, root):
self.root = root
self.path = root / "mock-device.json"
if self.path.exists():
self.state = json.loads(self.path.read_text())
else:
self.state = {
"current": 1,
"counter": 1,
"images": [
{"id": 1, "name": "Original Art.jpg", "createdAt": now_iso()}
],
}
img = Image.new("RGB", (1200, 1600), "#efe2ca")
img.save(root / "mock-1.jpg")
self.save()
self.online = True
self.synced = True
self.uploads = 0
self.displays = []
def save(self):
temporary = self.path.with_suffix(".tmp")
temporary.write_text(json.dumps(self.state))
temporary.replace(self.path)
def get_device(self):
return {
"nickname": "客厅相框",
"model": "13.3-inch",
"modelCode": "B101",
"firmware": "MOCK-B101",
"online": self.online,
"lastHeartbeat": now_iso(),
"batteryLevel": 40,
"isCharging": False,
"screenWidth": 1200,
"screenHeight": 1600,
"screenRotate": 270,
"timezone": "Asia/Shanghai",
"switchType": "queue",
"switchMinute": 60,
"sdUsedSize": 123456,
"imageId": self.state["current"],
"coverId": None,
"synced": self.synced,
}
def list_images(self):
return {
"total": len(self.state["images"]),
"list": [
dict(x, current=x["id"] == self.state["current"])
for x in reversed(self.state["images"])
],
}
def upload(self, path, name):
self.uploads += 1
self.state["counter"] += 1
image_id = self.state["counter"]
(self.root / f"mock-{image_id}.jpg").write_bytes(path.read_bytes())
self.state["images"].append(
{"id": image_id, "name": name, "createdAt": now_iso()}
)
self.state["current"] = image_id
self.save()
return image_id
def display(self, image_id):
if image_id not in [x["id"] for x in self.state["images"]]:
raise FlowError(E.NOT_FOUND, "Image not found", 404)
self.displays.append(image_id)
self.state["current"] = image_id
self.save()
def delete(self, image_id):
self.state["images"] = [x for x in self.state["images"] if x["id"] != image_id]
if self.state["current"] == image_id:
self.state["current"] = (
self.state["images"][-1]["id"] if self.state["images"] else None
)
(self.root / f"mock-{image_id}.jpg").unlink(missing_ok=True)
self.save()
def download(self, image_id):
path = self.root / f"mock-{image_id}.jpg"
if not path.exists():
raise FlowError(E.NOT_FOUND, "Image not found", 404)
return path.read_bytes()
def display_by_name(self, name):
matches = [x for x in self.state["images"] if x["name"] == name]
if not matches:
raise FlowError(E.NOT_FOUND, "Image not found", 404)
return self.display(matches[-1]["id"])