-
-
Notifications
You must be signed in to change notification settings - Fork 2
en Ui Sync Architecture
Real-time synchronization of UI state (theme, language, widget layout) across all connected clients: kiosk display, phone browser, PC browser.
SelenaCore serves the React SPA and all API endpoints from a single unified process on port 80. Real-time state synchronization between clients uses a WebSocket connection at /api/ui/sync with versioned state, snapshot delivery on connect, and delta event replay on reconnect.
Browser/Kiosk/Phone
|
| WebSocket /api/ui/sync?v=<last_version>
|
Core API :80 (unified: API + SPA + WebSocket)
|
SyncManager (singleton)
|- _version: int (monotonic counter)
|- _settings: {theme, language}
|- _layout: {pinned, sizes, positions, ...}
|- _event_log: deque(maxlen=256)
|- _clients: dict[id, WebSocket]
HTTPS on port 443 is handled by a lightweight Python TLS proxy (~5 MB RAM) that forwards to the main process on :80.
| Issue | Root cause |
|---|---|
| Kiosk screen freezes | SSE stream through httpx proxy created zombie connections with no health detection |
| Theme/language not syncing |
asyncio.QueueFull silently dropped SSE events (maxsize=64) |
| Different state on different clients | No state recovery after EventSource reconnect; missed events lost permanently |
| ~3 GB RAM usage | Two full uvicorn processes (Core :7070 + UI :80), each loading all modules |
Browser --> UI Server :80 (httpx proxy) --> Core API :7070
| |
|- SSE /api/ui/stream (lossy) |- EventBus
|- Static files (SPA) |- Module routers
|- 60-90 MB RAM wasted |- All business logic
Browser --> Core API :80 (direct)
|
|- API routes (/api/v1/*, /api/ui/*)
|- Module routers (/api/ui/modules/{name}/*)
|- WebSocket /api/ui/sync (versioned state)
|- Static SPA files (/assets/*, /icons/*, /*)
|- PWA (/manifest.json, /sw.js)
HTTPS :443 --> TLS proxy (asyncio, ~5 MB) --> :80
| Metric | Before | After |
|---|---|---|
| Python processes | 2-3 | 1 + TLS proxy |
| RAM usage | ~3 GB | ~1.5 GB |
| Proxy latency per request | 100-200 ms | 0 ms |
| Zombie connection detection | None | WebSocket ping/pong (5s) |
| State recovery on reconnect | None | Full snapshot or delta replay |
ws://host/api/ui/sync?v=<last_known_version>
wss://host/api/ui/sync?v=<last_known_version> (via TLS proxy)
1. Client connects with v=0 (first time) or v=N (reconnect)
2. Server sends initial state:
If v=0 or version too old:
<- {"type": "hello", "version": 5,
"settings": {"theme": "dark", "language": "uk"},
"layout": {"pinned": [...], "sizes": {...}, ...}}
If v>0 and events available in log:
<- {"type": "replay", "events": [
{"version": 3, "event_type": "settings_changed", "payload": {"theme": "dark"}},
{"version": 4, "event_type": "layout_changed", "payload": {...}}
]}
3. Server sends delta events as they occur:
<- {"type": "event", "version": 6,
"event_type": "settings_changed",
"payload": {"language": "en"}}
4. Server sends ping every 5 seconds:
<- {"type": "ping", "version": 6, "ts": 1712345678.0}
5. Client must respond with pong:
-> {"type": "pong"}
(no pong within 15s -> server closes connection)
6. On disconnect, client reconnects with v=<lastVersion>
and receives only missed events (or full snapshot if too old)
| Event | Trigger | Payload |
|---|---|---|
settings_changed |
POST /api/ui/settings |
{theme?, language?} |
layout_changed |
POST /api/ui/layout |
Full widget layout object |
module.started |
Module lifecycle | {name} |
module.stopped |
Module lifecycle | {name} |
module.removed |
Module lifecycle | {name} |
Singleton that holds the authoritative UI state:
- Versioned state: monotonic counter incremented on every change
-
Event log:
deque(maxlen=256)— stores recent events for replay (~50-100 KB) - Client registry: tracks connected WebSocket clients with last pong timestamp
- Dual broadcast: pushes events to both WebSocket clients and legacy SSE clients
from core.api.sync_manager import get_sync_manager
manager = get_sync_manager()
manager.get_snapshot() # Full state for new clients
manager.get_events_since(version=5) # Delta replay
await manager.update_settings({"theme": "dark"}) # Publish change
await manager.update_layout(layout_dict) # Publish layoutGET /api/ui/settings
-> {"theme": "auto", "language": "uk"}
POST /api/ui/settings
Content-Type: application/json
{"theme": "dark"}
-> {"ok": true}
(broadcasts settings_changed to all clients via WebSocket + SSE)React hook that monitors WebSocket health:
- Checks
lastServerContacttimestamp every 10 seconds - If no contact for 60 seconds, forces
window.location.reload() - Ensures the kiosk display never shows stale state
Lightweight Python asyncio script (embedded in scripts/start.sh):
- Listens on :443 with SSL context
- Forwards TCP connections to :80
- ~5 MB RAM (vs ~1.5 GB for a second uvicorn)
The React SPA is served directly by Core API (core/main.py):
-
/assets/*— Vite-built JS/CSS bundles (NoCacheStaticFiles) -
/icons/*— PWA icons (NoCacheStaticFiles) -
/manifest.json— PWA Web App Manifest -
/sw.js— Service Worker -
/{any_path}— SPA catch-all returnsindex.html
The SPA catch-all is registered after all module routers (during lifespan startup) to avoid intercepting /api/ui/modules/{name}/* routes.
Source: system_modules/ui_core/static/ (built by npx vite build)
| File | Status | Replacement |
|---|---|---|
system_modules/ui_core/server.py |
Gutted | Core API serves SPA directly |
system_modules/ui_core/routes/dashboard.py |
Gutted | Routes in core/api/routes/ui.py
|
system_modules/ui_core/wizard.py |
Gutted | Routes in core/api/routes/ui.py
|
SSE /api/ui/stream
|
Kept (backward compat) | WebSocket /api/ui/sync
|
All references to port 7070 have been updated:
-
core/config.py:core_portdefault = 80 -
scripts/start.sh: single uvicorn on :80 -
Dockerfile.core:EXPOSE 80 443 -
docker-compose.yml: healthcheck onhttp://localhost/api/v1/health -
smarthome-core.service:--port 80 - All system module
main.pyfiles:localhost:7070->localhost
The start.sh file is COPY'd into the Docker image (not volume-mounted). After editing scripts/start.sh, either:
- Rebuild:
docker compose up -d --build - Or manually:
docker cp scripts/start.sh selena-core:/opt/selena-core/start.sh && docker restart selena-core
After port changes in Python files, clear __pycache__ to avoid stale .pyc:
docker exec selena-core find /opt/selena-core -name "__pycache__" -type d -exec rm -rf {} +
docker restart selena-coreCheck /var/lib/selena/*.json for hardcoded port references:
docker exec selena-core grep -r "7070" /var/lib/selena/ --include="*.json"🤖 This wiki is auto-synced from docs/ in the main repo. Hand-edits on the wiki UI get overwritten on the next push. Open a PR against the main repo instead.
MIT License · Sponsor · Ko-fi
SelenaCore
🇬🇧 English
Getting started
Architecture
Voice & translation
Hardware integration
Development
- Modules overview
- Module development
- System module development
- Module API guide
- Module bus protocol
- Widget development
- User manager / auth
Reference
🇺🇦 Українська
Початок
Архітектура
Голос і переклад
Інтеграція заліза
Розробка
- Розробка модулів
- Розробка системних модулів
- Module API
- Module bus
- Widget development
- User manager / auth
Довідник