2026-10-02 00:03:48 +08:00
|
|
|
"""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 {},
|
2026-10-06 22:18:56 +08:00
|
|
|
timeout=(5, 120 if endpoint == "/upload" else 30),
|
2026-10-02 00:03:48 +08:00
|
|
|
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
|
|
|
|
|
)
|