Files
pigway-frame-flow/pigway_frame_flow/drivers/kokonna/client.py
T

163 lines
5.8 KiB
Python

"""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, 120 if endpoint == "/upload" else 30),
allow_redirects=False,
)
except requests.RequestException as exc:
# Persist only fixed categories: exception text and download URLs may
# contain credentials. Unknown writes must still never be replayed.
category = next(
(
label
for kind, label in (
(requests.ConnectTimeout, "connect_timeout"),
(requests.ReadTimeout, "read_timeout"),
(requests.exceptions.SSLError, "tls_error"),
(requests.ConnectionError, "connection_error"),
(requests.Timeout, "timeout"),
)
if isinstance(exc, kind)
),
"request_error",
)
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,
details={"transport_error": category},
) 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,
details={"http_status": response.status_code},
)
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
)