Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
601adfc
FrameOS Cloud phase 0: link backends and frames to cloud.frameos.net
mariusandra Jul 9, 2026
c633b7d
FrameOS Cloud phases 1 & 3: cloud login, config backups, feature mana…
mariusandra Jul 9, 2026
7b73d86
Cloud store publishing: publish templates, seed store repository
mariusandra Jul 10, 2026
8e5a01e
My cloud drive, save-to-cloud from frames, template modal polish
mariusandra Jul 10, 2026
2cdcc32
Scenes overview page, version-stamped exports, frameos-wasm npm package
mariusandra Jul 10, 2026
5f4bca8
Remove the /scenes overview page
mariusandra Jul 10, 2026
47f2fd2
Install scenes by pasting a store page URL into Templates search
mariusandra Jul 10, 2026
6737bfb
Preview HTTP goes client-side first; pasted URLs install to the frame
mariusandra Jul 11, 2026
124a5ae
Move the frameos-wasm package from frontend/wasm to frameos/wasm
mariusandra Jul 11, 2026
5919213
Preview event buttons send their label in the payload
mariusandra Jul 11, 2026
53f017a
Embeddable AGPL scene editor bundle + frameos-editor npm package
mariusandra Jul 11, 2026
c72c2c1
Embedded editor: theme support via init message
mariusandra Jul 11, 2026
e71eb8d
Standalone embed: synthetic 404s instead of hanging API plumbing
mariusandra Jul 11, 2026
7920dc6
Editor html references hashed entry files
mariusandra Jul 11, 2026
36973e0
Embedded editor: light-mode diagram styling
mariusandra Jul 11, 2026
000afc9
Embedded editor: full scene workspace panels + wasm live preview
mariusandra Jul 11, 2026
ebec342
Templates panel: "View store page" on cloud store templates
mariusandra Jul 12, 2026
4314a77
Auth-guard redirect uses replace, not push
mariusandra Jul 12, 2026
e320596
Embedded editor Preview panel: "Save screenshot"
mariusandra Jul 12, 2026
39ed67b
wasm manager: one action row; renders keep unapplied form edits
mariusandra Jul 12, 2026
b240023
No cloud-login option under Home Assistant ingress
mariusandra Jul 12, 2026
a73b951
Embedded flasher: reliable post-flash reboot and reconnect
mariusandra Jul 13, 2026
5dcc81e
Embedded: stream multi-MB PNGs instead of rejecting them
mariusandra Jul 13, 2026
000289c
Document the no-image-proxies-for-frames rule
mariusandra Jul 13, 2026
75a6085
Embedded: chunked HTTP bodies — no download size limit from fragmenta…
mariusandra Jul 13, 2026
2d725f1
Embedded: paint image download failures instead of 'No image provided'
mariusandra Jul 13, 2026
0b7e8f0
Backend: stale firmware cache invalidates on source changes
mariusandra Jul 13, 2026
f95f3aa
Embedded flasher: keep the state partition on re-flash
mariusandra Jul 13, 2026
60c3ae1
ESP32: serialize scenes.json writers
mariusandra Jul 13, 2026
147fb1e
Embedded: fix OTA acknowledgement and inline firmware controls
mariusandra Jul 13, 2026
6976176
ESP32: don't pin frame-sized images in the scene node cache
mariusandra Jul 14, 2026
d09265e
Backend: content-addressed firmware download URLs, no-store responses
mariusandra Jul 14, 2026
1523182
Regenerate builtinApps.ts for the gallery error-image change
mariusandra Jul 14, 2026
860ceb1
Frontend: retry the post-deploy USB preview until the first render ex…
mariusandra Jul 14, 2026
54fe99c
ESP32: render lifecycle on the USB serial stream, Pi event parity
mariusandra Jul 14, 2026
68748df
Embedded flasher: keep progress visible after the inline-controls move
mariusandra Jul 14, 2026
919359d
fix: harden cloud backup and device workflows
mariusandra Jul 14, 2026
8bfa4d6
Embedded: render error frames into the live canvas
mariusandra Jul 14, 2026
8fe40be
ESP32: restart when OOM-aborted renders leak the heap beyond recovery
mariusandra Jul 14, 2026
1e30e96
ESP32: sleep a full tick in console polls
mariusandra Jul 14, 2026
09adcbf
JS transpiler: stop template interpolations and comparisons from eati…
mariusandra Jul 14, 2026
30bfb8f
JS apps: HTTP requests, asset management, streams, and gated settings…
mariusandra Jul 14, 2026
142d9b4
Bird field journal: iNaturalist sightings become OpenAI field-journal…
mariusandra Jul 14, 2026
e531924
Cloud: safe feature scopes come with the link, only login toggles
mariusandra Jul 14, 2026
275b1c2
Embedded: USB commands fall back to HTTP when the port is stale
mariusandra Jul 14, 2026
af6d77d
Tests: included feature scopes ride along on link and feature changes
mariusandra Jul 14, 2026
96bb434
Update frontend visual snapshots
Jul 14, 2026
d58f3f2
Update frontend visual snapshots
Jul 14, 2026
f266fc5
Cloud: scene backups get their real name and an off switch
mariusandra Jul 14, 2026
642e7dc
Fix cloud, firmware, and editor review issues
mariusandra Jul 14, 2026
db00449
Split out ESP32, bird journal / JS runtime, and editor work
mariusandra Jul 14, 2026
8b64f4c
Update frontend visual snapshots
Jul 15, 2026
5f50488
Merge remote-tracking branch 'origin/main' into backend-cloud-connect
mariusandra Jul 15, 2026
4da7bd7
Merge remote-tracking branch 'origin/backend-cloud-connect' into back…
mariusandra Jul 15, 2026
b854568
Merge remote-tracking branch 'origin/main' into backend-cloud-connect
mariusandra Jul 18, 2026
8606114
Merge cloud-config-backups into backend-cloud-connect
mariusandra Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .chats import * # noqa: E402, F403
from .cloud import * # noqa: E402, F403
from .cloud_backups import * # noqa: E402, F403
from .cloud_store import * # noqa: E402, F403
from .embedded_device import * # noqa: E402, F403
from .frame_bootstrap import * # noqa: E402, F403
from .frames import * # noqa: E402, F403
Expand Down
169 changes: 169 additions & 0 deletions backend/app/api/cloud_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Publish scenes to the FrameOS Cloud store (STORE-TODO Phases 2-3).

The payload is the same template interchange zip the backups use; the cloud
keeps immutable versions per scene. Publishing needs the ``store:publish``
scope on the cloud link. Browsing the public store needs nothing at all —
it is a plain scenes repository at ``{provider}/api/store/repository.json``
(auto-added per project in app/api/repositories.py).

"My cloud drive" is the account's own scenes, private ones included. The
browser cannot attach the link token to <img> tags, so this module proxies
the drive listing and preview images; zips install through the normal
``POST /api/templates {url}`` flow, which attaches the link token for
provider URLs (see cloud_headers_for_url).
"""
from __future__ import annotations

import base64
import io
from http import HTTPStatus

from arq import ArqRedis as Redis
from fastapi import Depends, HTTPException
from fastapi.responses import Response
from PIL import Image
from sqlalchemy.orm import Session

from app.api.auth import get_current_user
from app.api.cloud_backups import _require_linked, _require_user
from app.api.templates import template_zip_bytes
from app.database import get_db
from app.models.frame import Frame
from app.models.template import Template
from app.models.user import User
from app.redis import get_redis
from app.schemas.cloud import CloudStatusResponse, CloudStorePublishRequest
from app.tenancy import get_user_project
from app.utils import cloud_link

from . import api_user


async def _template_from_request(
data: CloudStorePublishRequest, db: Session, redis: Redis, user: User
) -> Template:
"""The template to publish: an existing one, or a transient one built
from inline scenes ("Save to cloud drive" straight off a frame)."""
if data.template_id:
template = db.query(Template).filter_by(id=data.template_id).first()
if template is None or get_user_project(db, user, template.project_id) is None:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Template not found")
return template

if not data.name or not data.scenes:
raise HTTPException(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
detail="Provide template_id, or name and scenes",
)

template = Template(name=data.name, description=data.description, scenes=data.scenes, config={})

if data.from_frame_id:
frame = db.query(Frame).filter_by(id=data.from_frame_id).first()
if frame is None or get_user_project(db, user, frame.project_id) is None:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
template.project_id = frame.project_id
image = None
if data.image_scene_id:
from app.models.scene_image import SceneImage

scene_image = (
db.query(SceneImage)
.filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id=data.image_scene_id)
.first()
)
if scene_image:
image = scene_image.image
if not image:
image = await redis.get(f"frame:{frame.id}:image")
if image:
try:
img_obj = Image.open(io.BytesIO(image))
template.image = image
template.image_width = img_obj.width
template.image_height = img_obj.height
except Exception: # noqa: BLE001 — a broken snapshot just means no preview
pass

return template


@api_user.post("/cloud/store/publish", response_model=CloudStatusResponse)
async def publish_template_to_store(
data: CloudStorePublishRequest,
db: Session = Depends(get_db),
redis: Redis = Depends(get_redis),
current_user: User | None = Depends(get_current_user),
):
user = _require_user(current_user)
link, access_token = _require_linked(db, "store:publish")
template = await _template_from_request(data, db, redis, user)

payload: dict = {
"name": template.name,
"content_base64": base64.b64encode(template_zip_bytes(template)).decode(),
"content_type": "application/zip",
}
if template.description:
payload["description"] = template.description
# Omitted visibility means: private on first publish, unchanged afterwards.
if data.visibility in ("private", "public"):
payload["visibility"] = data.visibility

try:
status_code, response = await cloud_link.store_publish(link.provider_url, access_token, payload)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}"
) from exc
if status_code != 200:
detail = response.get("error") or f"unexpected status {status_code}"
raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}")
return {"status": "published", "scene": response.get("scene")}


@api_user.get("/cloud/store/drive", response_model=CloudStatusResponse)
async def cloud_store_drive(
db: Session = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
_require_user(current_user)
link, access_token = _require_linked(db, "store:publish")
try:
status_code, payload = await cloud_link.store_drive(link.provider_url, access_token)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}"
) from exc
if status_code != 200:
detail = payload.get("error") or f"unexpected status {status_code}"
raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}")

# Preview images of private scenes need the link token, which the browser
# does not have — point them at our authenticated proxy below instead.
for template in payload.get("templates", []):
scene_id = template.get("sceneId")
if scene_id and template.get("image"):
template["image"] = f"/api/cloud/store/drive/image/{scene_id}"
return payload


@api_user.get("/cloud/store/drive/image/{scene_id}")
async def cloud_store_drive_image(
scene_id: str,
db: Session = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
_require_user(current_user)
link, access_token = _require_linked(db, "store:publish")
try:
status_code, content_type, content = await cloud_link.cloud_get_binary(
link.provider_url, f"/api/store/scenes/{scene_id}/image", access_token
)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}"
) from exc
if status_code != 200:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found")
return Response(content, media_type=content_type, headers={"cache-control": "private, max-age=300"})
48 changes: 48 additions & 0 deletions backend/app/api/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

FRAMEOS_SAMPLES_URL = "https://repo.frameos.net/samples/repository.json"
FRAMEOS_GALLERY_URL = "https://repo.frameos.net/gallery/repository.json"
CLOUD_STORE_REPOSITORY_MARKER = "@system/cloud_store_repository_added"

SYSTEM_REPOSITORIES_PATH = Path(__file__).resolve().parents[3] / "repo" / "scenes"

Expand Down Expand Up @@ -284,6 +285,53 @@ async def get_repositories(db: Session = Depends(get_db)):
db.add(Settings(project_id=project_id, key="@system/repository_global_cleanup", value="true"))
db.commit()

# Seed the connected provider's store once per provider. The marker
# records its URL so changing providers replaces the old store, while
# deleting the repository remains respected until the provider changes.
from app.models.cloud import current_cloud_backend_link

link = current_cloud_backend_link(db)
if link is not None and link.status == "connected" and link.provider_url:
store_url = link.provider_url.rstrip("/") + "/api/store/repository.json"
marker = db.query(Settings).filter_by(
project_id=project_id, key=CLOUD_STORE_REPOSITORY_MARKER
).first()
marker_url = marker.value if marker is not None and isinstance(marker.value, str) else None
should_seed = marker is None or (marker_url is not None and marker_url != store_url)

if marker is not None and marker_url in (None, "true"):
# Migrate the old boolean marker. An absent repository is
# ambiguous and may have been explicitly deleted, so preserve
# that choice. An old provider repository makes a URL change
# observable and safe to migrate.
legacy_repositories = db.query(Repository).filter(
Repository.project_id == project_id,
Repository.url.like("%/api/store/repository.json"),
).all()
should_seed = bool(legacy_repositories) and all(repo.url != store_url for repo in legacy_repositories)
for repository in legacy_repositories:
if repository.url != store_url:
db.delete(repository)
marker.value = store_url
elif marker is not None and marker_url != store_url:
old_repository = db.query(Repository).filter_by(project_id=project_id, url=marker_url).first()
if old_repository is not None:
db.delete(old_repository)
marker.value = store_url

if marker is None:
marker = Settings(project_id=project_id, key=CLOUD_STORE_REPOSITORY_MARKER, value=store_url)
db.add(marker)

if should_seed and not db.query(Repository).filter_by(project_id=project_id, url=store_url).first():
store_repository = Repository(project_id=project_id, name="FrameOS Cloud store", url=store_url)
try:
await store_repository.update_templates()
except Exception: # noqa: BLE001 — provider may be unreachable; seed anyway
pass
db.add(store_repository)
db.commit()

repositories = project_query(db, Repository).all()

for r in repositories:
Expand Down
73 changes: 66 additions & 7 deletions backend/app/api/templates.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import base64
import io
import re
import urllib.parse
import zipfile
import json
import string
Expand Down Expand Up @@ -37,6 +39,29 @@ def safe_template_name(template: Template) -> str:
return ' '.join(template_name.split()) or 'Template'


_FRAMEOS_ZIP_META_RE = re.compile(
r'<meta\s+[^>]*?(?:name=["\']frameos:zip["\'][^>]*?content=["\']([^"\']+)["\']'
r'|content=["\']([^"\']+)["\'][^>]*?name=["\']frameos:zip["\'])',
re.IGNORECASE,
)


def frameos_zip_url_from_html(content: bytes, page_url: str) -> str | None:
"""The template zip URL a scene page advertises via
<meta name="frameos:zip" content="...">, resolved against the page URL."""
try:
html = content[:262144].decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
return None
match = _FRAMEOS_ZIP_META_RE.search(html)
if not match:
return None
zip_url = (match.group(1) or match.group(2) or "").strip().replace("&amp;", "&")
if not zip_url:
return None
return urllib.parse.urljoin(page_url, zip_url)


def template_zip_bytes(template: Template) -> bytes:
"""The template interchange zip: {name}/template.json + scenes.json + image.jpg.
Also the payload format for cloud template backups (CLOUD-TODO Phase 3)."""
Expand Down Expand Up @@ -133,6 +158,9 @@ async def create_template(
name = name or parsed_json.get('name')
description = description or parsed_json.get('description')
from_frame_id = from_frame_id or parsed_json.get('from_frame_id')
# When saving specific scenes off a frame, the preview should be the
# snapshot of one of *those* scenes, not whatever the frame shows now.
image_scene_id = parsed_json.get('image_scene_id')

# Scenes/config might come as JSON arrays or as strings
if not scenes and parsed_json.get('scenes') is not None:
Expand All @@ -157,11 +185,28 @@ async def create_template(
file_bytes = await file.read()
zip_file = zipfile.ZipFile(io.BytesIO(file_bytes))
elif url:
# If we have a URL, fetch it
# If we have a URL, fetch it. URLs on the linked cloud provider get
# the link token attached so private "My cloud drive" scenes install.
from app.utils.cloud_backup import cloud_headers_for_url

async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
zip_file = zipfile.ZipFile(io.BytesIO(resp.content))
resp = await client.get(url, headers=cloud_headers_for_url(db, url))
resp.raise_for_status()
content = resp.content
if not zipfile.is_zipfile(io.BytesIO(content)):
# Not a zip: maybe a scene page (e.g. a FrameOS Cloud store
# page) that advertises its zip in a <meta name="frameos:zip">
# tag — so pasting the page URL installs the scene.
zip_url = frameos_zip_url_from_html(content, url)
if not zip_url:
raise HTTPException(
status_code=422,
detail="URL is neither a template .zip nor a page with a frameos:zip meta tag",
)
resp = await client.get(zip_url, headers=cloud_headers_for_url(db, zip_url))
resp.raise_for_status()
content = resp.content
zip_file = zipfile.ZipFile(io.BytesIO(content))

data = {
"from_frame_id": from_frame_id,
Expand Down Expand Up @@ -207,8 +252,10 @@ async def create_template(
image_path = img_val[len('./'):]
img_val = zip_file.read(f'{folder_name}{image_path}')
elif img_val.startswith('http:') or img_val.startswith('https:'):
from app.utils.cloud_backup import cloud_headers_for_url

async with httpx.AsyncClient() as client:
resp = await client.get(img_val)
resp = await client.get(img_val, headers=cloud_headers_for_url(db, img_val))
resp.raise_for_status()
img_val = resp.content
else:
Expand All @@ -225,8 +272,20 @@ async def create_template(
frame_id = data['from_frame_id']
frame = db.query(Frame).filter_by(project_id=project_id, id=frame_id).first()
if frame:
cache_key = f'frame:{frame.id}:image'
last_image = await redis.get(cache_key)
last_image = None
if image_scene_id:
from app.models.scene_image import SceneImage

scene_image = (
db.query(SceneImage)
.filter_by(project_id=project_id, frame_id=frame.id, scene_id=image_scene_id)
.first()
)
if scene_image:
last_image = scene_image.image
if not last_image:
cache_key = f'frame:{frame.id}:image'
last_image = await redis.get(cache_key)
if last_image:
try:
img_obj = Image.open(io.BytesIO(last_image))
Expand Down
Loading
Loading