111 lines
3.8 KiB
Python
111 lines
3.8 KiB
Python
"""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)
|