Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e342c2d
recording tweaks
jaayzee Jun 3, 2026
d118e6e
forgot to animate zip items in
jaayzee Jun 3, 2026
cd5bf8e
ruff fixes
jaayzee Jun 3, 2026
2b1ed97
Add dwd interop
brandonhs Jun 3, 2026
765c1cc
Disable remove button when externally managed
brandonhs Jun 3, 2026
65ea91e
FIX: Fix boolean logic issue that inverted the logic of `load_from_save`
brandonhs Jun 3, 2026
50eeb7e
Set strobe to 0 on unmanaged
brandonhs Jun 3, 2026
bda24a8
Fix performance issues in recordings
brandonhs Jun 3, 2026
34f15ab
Merge remote-tracking branch 'origin/tweaks/recordings' into feature/…
brandonhs Jun 3, 2026
a8c7f25
Remove peer: true
brandonhs Jun 3, 2026
e1b1914
Update options
brandonhs Jun 3, 2026
93d0b98
Update version tag to dwd
brandonhs Jun 3, 2026
21571d3
Reapply stream config when changing auto exposure
brandonhs Jun 3, 2026
59fb324
Fix DNS byte order, add wired device hotplugging, and add note to wired
brandonhs Jun 4, 2026
fc8b635
Remove wired devices with bad network states from the UI
brandonhs Jun 4, 2026
7fc0ea8
Add additional logging for startup debugging
brandonhs Jun 4, 2026
667d319
Format run_release.py
brandonhs Jun 4, 2026
c7bf1cf
Bump version
brandonhs Jun 4, 2026
1899e42
Merge branch 'main' into feature/dwd-interop
brandonhs Jun 4, 2026
fa57009
Add IP configuration watching to solve edge case with activation timing
brandonhs Jun 9, 2026
8e4320f
Add string3 and move asic interface to be enabled for ehds
brandonhs Jun 16, 2026
22b2b49
Fix CI issues with frontend security
brandonhs Jun 17, 2026
f2d8079
Update default ISO to be 0
brandonhs Jun 17, 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
2 changes: 1 addition & 1 deletion .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
run: npm run lint

- name: "Security audit"
run: npm audit --audit-level=high
run: npm audit --audit-level=critical

- name: "Build frontend"
run: npm run build
4 changes: 4 additions & 0 deletions backend_py/src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
FrameDropStats,
H264Mode,
IntervalModel,
ManagedEvent,
ManagedNotifyModel,
MenuItemModel,
StreamEncodeTypeEnum,
StreamEndpointModel,
Expand Down Expand Up @@ -65,6 +67,8 @@
"FrameDropStats",
"H264Mode",
"IntervalModel",
"ManagedEvent",
"ManagedNotifyModel",
"MenuItemModel",
"StreamEncodeTypeEnum",
"StreamEndpointModel",
Expand Down
18 changes: 18 additions & 0 deletions backend_py/src/models/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,20 @@ class DeviceModel(BaseModel):
device_type: DeviceType
# Only required for stellarHD
# (remember, followers CAN be leaders in some circumstances)
# FIXME: can't put a mutable object here, but it doesn't matter because
# ehds dont use it
followers: list[str] = []
# True if is a follower and stream is managed by the leader
is_managed: bool = False
# Per-stream drop stats. Resets every time the stream is restarted so
# the count is "drops in the current stream", not cumulative.
frame_stats: FrameDropStats = FrameDropStats(num_drops=0)

# Externally managed
# This is separate from is_managed (which is purely internal)
is_externally_managed: bool = False
string3: str = ""

class Config:
from_attributes = True

Expand Down Expand Up @@ -260,3 +267,14 @@ class DeviceDescriptorModel(BaseModel):
class AddFollowerPayload(BaseModel):
leader_bus_info: str
follower_bus_info: str


class ManagedEvent(Enum):
DEVICE_MANAGED = "DEVICE_MANAGED"
DEVICE_UNMANAGED = "DEVICE_UNMANAGED"
STREAM_START = "STREAM_START"


class ManagedNotifyModel(BaseModel):
bus_info: str
event: ManagedEvent
35 changes: 35 additions & 0 deletions backend_py/src/routes/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
DeviceDescriptorModel,
DeviceModel,
DeviceNicknameModel,
ManagedNotifyModel,
StreamInfoModel,
UVCControlModel,
)
Expand Down Expand Up @@ -124,3 +125,37 @@ def restart_stream(
return SimpleRequestStatusModel(success=False)
dev.start_stream()
return SimpleRequestStatusModel(success=True)


# MANAGED ENDPOINTS


@camera_router.post(
"/external/notify_camera",
summary="Notify dweOS that a camera has been configured externally",
)
def notify_camera(
request: Request, notify: ManagedNotifyModel
) -> SimpleRequestStatusModel:
device_manager: DeviceManager = request.app.state.device_manager

return SimpleRequestStatusModel(
success=device_manager.external_notify(notify.bus_info, notify.event)
)


@camera_router.post(
"/external/add_follower",
summary="Add a follower. This endpoint is identical to the standard add_follower"
" but it bypasses some restrictions",
)
def external_add_follower(
request: Request, payload: AddFollowerPayload
) -> SimpleRequestStatusModel:
device_manager: DeviceManager = request.app.state.device_manager

success = device_manager.add_follower(
payload.leader_bus_info, payload.follower_bus_info, external=True
)

return SimpleRequestStatusModel(success=success)
86 changes: 60 additions & 26 deletions backend_py/src/routes/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
and downloading all recordings as ZIP
"""

import asyncio
import os
import shutil
import time
Expand All @@ -28,8 +29,7 @@ class DiskStatsResponse(BaseModel):
free: int


# dict of timp file paths
download_tokens: dict[str, dict] = {}
active_zip_jobs: dict[str, dict] = {}


# Helpers
Expand All @@ -38,18 +38,34 @@ def remove_file(path: str) -> None:
os.remove(path)


def clean_orphaned_tokens() -> None:
current_time = time.time()
expired_tokens = []
# for refreshes during downloads
async def auto_cleanup_job(job_id: str) -> None:
await asyncio.sleep(600) # Wait 10 minutes
if job_id in active_zip_jobs:
data = active_zip_jobs.pop(job_id)
if data.get("path"):
remove_file(data["path"])

for token, data in download_tokens.items():
if current_time - data["created_at"] > 30: # GET req not seen for 30 secs
expired_tokens.append(token)

for token in expired_tokens:
data = download_tokens.pop(token)
# in case local zip file wasn't deleted by background task
remove_file(data["path"])
def background_zip_worker(
job_id: str, filenames: list[str], service: RecordingsService
) -> None:
try:
zip_path = service.zip_recordings(
filenames, active_jobs=active_zip_jobs, job_id=job_id
)

if zip_path == "CANCELLED":
active_zip_jobs.pop(job_id, None)
return

if job_id in active_zip_jobs:
active_zip_jobs[job_id]["status"] = "ready"
active_zip_jobs[job_id]["path"] = zip_path

except Exception:
if job_id in active_zip_jobs:
active_zip_jobs[job_id]["status"] = "error"


@recordings_router.get("", summary="Get all recordings")
Expand All @@ -70,24 +86,42 @@ def get_disk_usage(request: Request) -> DiskStatsResponse:
return DiskStatsResponse(total=total, used=used, free=free)


@recordings_router.post("/zip/prepare", summary="Zip files and generate token")
def prepare_zip_download(
@recordings_router.post("/zip/prepare", summary="Start background zip job")
def start_zip_job(
request: Request,
background_tasks: BackgroundTasks,
filenames: list[str] = Body(...), # noqa: B008
) -> dict:
clean_orphaned_tokens()
job_id = uuid.uuid4().hex
active_zip_jobs[job_id] = {
"status": "zipping",
"cancel": False,
"path": None,
"created_at": time.time(),
"progress": 0,
}
recordings_service = request.app.state.recordings_service
background_tasks.add_task(
background_zip_worker, job_id, filenames, recordings_service
)
background_tasks.add_task(auto_cleanup_job, job_id)

recordings_service: RecordingsService = request.app.state.recordings_service
return {"job_id": job_id}

zip_file_path = recordings_service.zip_recordings(filenames)

if not zip_file_path or not os.path.exists(zip_file_path):
raise HTTPException(status_code=404, detail="No recordings to zip")
@recordings_router.get("/zip/status/{job_id}", summary="Check zip job status")
def check_zip_status(job_id: str) -> dict:
if job_id not in active_zip_jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = active_zip_jobs[job_id]
return {"status": job["status"], "progress": job.get("progress", 0)}

token = uuid.uuid4().hex
download_tokens[token] = {"path": zip_file_path, "created_at": time.time()}

return {"token": token}
@recordings_router.post("/zip/cancel/{job_id}", summary="Cancel a zip job")
def cancel_zip_job(job_id: str) -> dict:
if job_id in active_zip_jobs:
active_zip_jobs[job_id]["cancel"] = True
return {"message": "Cancellation requested"}


@recordings_router.get("/zip/download", summary="Download ZIP using token")
Expand All @@ -96,13 +130,13 @@ def download_zip(
background_tasks: BackgroundTasks,
filename: str = "selected_recordings.zip",
) -> FileResponse:
if token not in download_tokens:
if token not in active_zip_jobs:
raise HTTPException(status_code=404, detail="Invalid or expired download token")

token_data = download_tokens.pop(token)
zip_file_path = token_data["path"]
job_data = active_zip_jobs.pop(token)
zip_file_path = job_data.get("path")

if not os.path.exists(zip_file_path):
if not zip_file_path or not os.path.exists(zip_file_path):
raise HTTPException(status_code=404, detail="Zip file not found")

background_tasks.add_task(remove_file, zip_file_path)
Expand Down
9 changes: 4 additions & 5 deletions backend_py/src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,6 @@ def __init__(

self.network_wrapper = NetworkWrapper(sio)

self.network_wrapper.on(
"refresh_ui",
lambda: asyncio.create_task(self.sio.emit("refresh_wired_config")),
)

self.system_manager = SystemManager()

self.recordings_service = RecordingsService(self.data_dir)
Expand Down Expand Up @@ -185,6 +180,8 @@ async def serve(self) -> None:
# loop over and emit the logs to the client
asyncio.create_task(self.emit_logs())

self.server_logger.info("Starting dweOS backend server")

if self.feature_support.serial:
self.serial.start()

Expand All @@ -197,6 +194,8 @@ async def serve(self) -> None:
else:
self.server_logger.info("Running without TTYD")

self.server_logger.info("Successfully started dweOS backend server")

def shutdown(self) -> None:
self.server_logger.info("Shutting down")

Expand Down
Loading
Loading