Add optional photo providers, layout studio and scoped integrations

This commit is contained in:
Pigway
2026-10-08 23:18:11 +08:00
parent 3643fae86a
commit 69a0841868
38 changed files with 2843 additions and 41 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Opt-in standard HTTP frame protocol for privately managed devices."""
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Device, GalleryImage
from pigway_frame_flow.settings import secret
class GenericHTTPDriver:
def __init__(self, id, entry, settings):
from urllib.parse import urlsplit
self.id, self.entry, self.settings = id, entry, settings
self.endpoint = entry.get("endpoint", "").rstrip("/")
parsed = urlsplit(self.endpoint)
if (
parsed.scheme not in ("https", "http")
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
raise ValueError("Invalid generic frame endpoint")
if parsed.scheme == "http" and not entry.get("allow_insecure_lan", False):
raise ValueError("HTTP frames require explicit allow_insecure_lan")
self.token = secret(entry.get("token_env", "FRAME_FLOW_HTTP_FRAME_TOKEN"))
settings.additional_secrets.append(self.token)
def call(self, method, path, **kwargs):
writing = method != "GET"
if writing and not self.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
try:
response = requests.request(
method,
self.endpoint + path,
headers={"Authorization": "Bearer " + self.token} if self.token else {},
timeout=10,
allow_redirects=False,
**kwargs,
)
response.raise_for_status()
return response
except requests.RequestException:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if writing else E.CLOUD_UNAVAILABLE,
"HTTP frame request failed",
409 if writing else 502,
) from None
def invalidate(self):
pass
def device(self, refresh=False):
raw = self.call("GET", "/device").json()
raw["id"] = self.id
raw["driver"] = "generic_http_frame"
return Device.model_validate(raw)
def get_current_image(self):
return self.device().current_content.id
def health(self):
try:
self.device()
return True
except FlowError:
return False
def images(self):
return [
GalleryImage.model_validate(x) for x in self.call("GET", "/images").json()
]
def upload(self, path, name):
with path.open("rb") as f:
return int(
self.call(
"POST", "/images", files={"file": (name, f, "image/jpeg")}
).json()["image_id"]
)
def display(self, image_id):
self.call("POST", "/display", json={"image_id": image_id})
def delete(self, image_id):
self.call("DELETE", f"/images/{int(image_id)}")
def download(self, image_id):
response = self.call("GET", f"/images/{int(image_id)}/content", stream=True)
try:
parts = []
total = 0
for chunk in response.iter_content(65536):
total += len(chunk)
if total > self.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
parts.append(chunk)
return b"".join(parts)
finally:
response.close()
def display_by_name(self, name):
matches = [x for x in self.images() if x.name == name]
if len(matches) != 1:
raise FlowError(E.CONFLICT, "Filename must match exactly one image", 409)
self.display(matches[0].image_id)