Add optional photo providers, layout studio and scoped integrations
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user