Skip to content

Commit d89ee67

Browse files
vpeterssonclaude
andcommitted
feat: simplify architecture from 6 containers to 2
- Delete legacy API versions (v1, v1.1, v1.2), keep only v2 - Replace Celery + Redis with threading-based background tasks - Replace React/TypeScript/webpack frontend with Django templates + HTMX + Bootstrap 5 - Replace ZMQ pub/sub with Django Channels WebSocket - Replace Nginx with WhiteNoise for static file serving - Replace Gunicorn with Daphne (ASGI) for WebSocket support - Simplify Docker from 6 services to 2 (server + viewer) - Migrate test suite from unittest to pytest - Remove ~20,000 lines of code and dozens of dependencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ed7977c commit d89ee67

File tree

144 files changed

+2826
-21955
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

144 files changed

+2826
-21955
lines changed

.prettierrc

Lines changed: 0 additions & 25 deletions
This file was deleted.

CLAUDE.md

Lines changed: 23 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,21 @@ Anthias is an open-source digital signage platform for Raspberry Pi and x86 PCs
88

99
## Architecture
1010

11-
Anthias runs as a set of Docker containers:
11+
Anthias runs as 2 Docker containers:
1212

13-
- **anthias-nginx** (port 80) — Reverse proxy, static file serving
14-
- **anthias-server** (port 8000) — Django web app serving the React frontend and REST API
15-
- **anthias-celery** — Async task queue (asset downloads, cleanup)
16-
- **anthias-websocket** (port 9999) — Real-time updates
17-
- **anthias-viewer** — Drives the display, receives instructions via ZMQ
18-
- **redis** (port 6379) — Message broker, cache, database
19-
- **webview** — Qt-based browser for rendering content on the display
13+
- **anthias-server** (port 8000) — Django ASGI app (Daphne) serving the HTMX UI, REST API, WebSocket endpoints, and static files (WhiteNoise)
14+
- **anthias-viewer** — Drives the display via Qt WebView, receives commands via WebSocket
2015

21-
Inter-service communication uses ZMQ (port 10001 publisher, 5558 collector). The primary database is SQLite stored at `~/.screenly/screenly.db`, with configuration in `~/.screenly/screenly.conf`.
16+
Inter-service communication uses Django Channels WebSocket (`/ws/viewer/` for viewer commands, `/ws/ui/` for UI updates). The primary database is SQLite stored at `~/.screenly/screenly.db`, with configuration in `~/.screenly/screenly.conf`.
2217

2318
### Key Directories
2419

25-
- `anthias_app/` — Django app (models, views, migrations, management commands)
26-
- `anthias_django/` — Django project settings, URLs, ASGI/WSGI
27-
- `api/` — REST API (views, serializers, URLs for v1, v1.1, v1.2, v2)
28-
- `static/src/` — TypeScript/React frontend (components, Redux store, hooks, tests)
29-
- `viewer/` — Viewer service (scheduling, media player, ZMQ communication)
20+
- `anthias_app/` — Django app (models, views, templates, migrations, tasks, consumers)
21+
- `anthias_django/` — Django project settings, URLs, ASGI routing
22+
- `api/` — REST API v2 (views, serializers, URLs)
23+
- `viewer/` — Viewer service (scheduling, media player, WebSocket client)
3024
- `webview/` — C++ Qt-based WebView (Qt5 for Pi 1-4, Qt6 for Pi 5/x86)
31-
- `lib/` — Shared Python utilities (auth, device helpers, diagnostics, ZMQ)
25+
- `lib/` — Shared Python utilities (auth, device helpers, diagnostics)
3226
- `docker/` — Dockerfile Jinja2 templates for each service
3327
- `tests/` — Python unit/integration tests
3428
- `bin/` — Shell scripts for install, dev setup, testing, upgrades
@@ -39,37 +33,17 @@ Inter-service communication uses ZMQ (port 10001 publisher, 5558 collector). The
3933
### Dev Environment
4034

4135
```bash
42-
./bin/start_development_server.sh # Start full dev environment (Docker)
43-
docker compose -f docker-compose.dev.yml down # Stop dev server
36+
docker compose -f docker-compose.dev.yml up --build # Start dev environment
37+
docker compose -f docker-compose.dev.yml down # Stop dev server
4438
# Web UI at http://localhost:8000
4539
```
4640

47-
### Frontend (TypeScript/React)
48-
49-
```bash
50-
npm install
51-
npm run dev # Webpack watch mode
52-
npm run build # Production build
53-
npm run lint:check # ESLint check
54-
npm run lint:fix # ESLint fix
55-
npm run format:check # Prettier check
56-
npm run format:fix # Prettier fix
57-
npm run test # Jest tests
58-
```
59-
60-
Inside Docker:
61-
```bash
62-
docker compose -f docker-compose.dev.yml exec anthias-server npm run dev
63-
```
64-
6541
### Python Dependencies
6642

67-
All Python dependencies are managed via `pyproject.toml` dependency groups, with `uv.lock` for reproducible installs. Each Docker service has its own group:
43+
All Python dependencies are managed via `pyproject.toml` dependency groups, with `uv.lock` for reproducible installs:
6844

6945
- `server` — Django web app (anthias-server)
70-
- `celery` — Celery worker (includes server group)
71-
- `websocket` — WebSocket service
72-
- `viewer` — Viewer service (standalone Dockerfile)
46+
- `viewer` — Viewer service
7347
- `wifi-connect` — WiFi Connect service
7448
- `dev` — Test utilities (mock, selenium, etc.)
7549
- `test` — Combined group (includes server + dev + viewer)
@@ -95,10 +69,10 @@ uv run ruff check /path/to/file.py # Lint specific file
9569

9670
```bash
9771
# Build and start test containers
98-
uv run python -m tools.image_builder --dockerfiles-only --disable-cache-mounts --service celery --service redis --service test
72+
uv run python -m tools.image_builder --dockerfiles-only --disable-cache-mounts --service test
9973
docker compose -f docker-compose.test.yml up -d --build
10074

101-
# Prepare and run tests (integration and non-integration must be run separately)
75+
# Prepare and run tests
10276
docker compose -f docker-compose.test.yml exec anthias-test bash ./bin/prepare_test_environment.sh -s
10377
docker compose -f docker-compose.test.yml exec anthias-test ./manage.py test --exclude-tag=integration
10478
docker compose -f docker-compose.test.yml exec anthias-test ./manage.py test --tag=integration
@@ -120,17 +94,16 @@ docker compose exec anthias-server python manage.py createsuperuser
12094
- Use type hints
12195
- Exclude comments in generated code
12296

123-
### TypeScript/React
124-
- Functional components only (no class components)
125-
- Redux Toolkit (`createSlice`, `createAsyncThunk`) for state management
126-
- No `any` or `unknown` types
127-
- Don't explicitly import React (handled by webpack ProvidePlugin)
128-
- Import order: built-in → third-party → local (alphabetically sorted, blank line between groups)
129-
- Use `rem` instead of `px` in SCSS
97+
### Frontend (Django Templates + HTMX)
98+
- Django templates with HTMX for dynamic interactions
99+
- Bootstrap 5 via CDN for styling
100+
- SortableJS via CDN for drag-and-drop
101+
- Font Awesome via CDN for icons
102+
- Templates located in `anthias_app/templates/anthias_app/`
130103

131104
### Qt/C++ (WebView)
132105
- Use macros for Qt5/Qt6 cross-version compatibility
133106

134-
## API Versions
107+
## API
135108

136-
The REST API has multiple versions at `/api/v1/`, `/api/v1.1/`, `/api/v1.2/`, and `/api/v2/`. The v2 API (in `api/views/v2.py`) is the current primary API using DRF with drf-spectacular for OpenAPI schema generation.
109+
The REST API is at `/api/v2/`. The v2 API (in `api/views/v2.py`) uses DRF with drf-spectacular for OpenAPI schema generation. API docs at `/api/docs/`.

anthias_app/apps.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import os
2+
13
from django.apps import AppConfig
24

35

46
class AnthiasAppConfig(AppConfig):
57
default_auto_field = 'django.db.models.BigAutoField'
68
name = 'anthias_app'
9+
10+
def ready(self):
11+
if os.environ.get('RUN_MAIN') != 'true':
12+
return
13+
14+
from anthias_app.tasks import start_background_scheduler
15+
16+
start_background_scheduler()

anthias_app/consumers.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import json
2+
import logging
3+
4+
from channels.generic.websocket import WebSocketConsumer
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class ViewerConsumer(WebSocketConsumer):
10+
def connect(self):
11+
from channels.layers import get_channel_layer
12+
13+
self.accept()
14+
channel_layer = get_channel_layer()
15+
if channel_layer is not None:
16+
from asgiref.sync import async_to_sync
17+
18+
async_to_sync(channel_layer.group_add)(
19+
'viewer', self.channel_name
20+
)
21+
logger.info('Viewer WebSocket connected')
22+
23+
def disconnect(self, close_code):
24+
from channels.layers import get_channel_layer
25+
26+
channel_layer = get_channel_layer()
27+
if channel_layer is not None:
28+
from asgiref.sync import async_to_sync
29+
30+
async_to_sync(channel_layer.group_discard)(
31+
'viewer', self.channel_name
32+
)
33+
logger.info('Viewer WebSocket disconnected')
34+
35+
def viewer_command(self, event):
36+
self.send(text_data=json.dumps({
37+
'command': event['command'],
38+
'data': event.get('data'),
39+
}))
40+
41+
42+
class UIConsumer(WebSocketConsumer):
43+
def connect(self):
44+
from channels.layers import get_channel_layer
45+
46+
self.accept()
47+
channel_layer = get_channel_layer()
48+
if channel_layer is not None:
49+
from asgiref.sync import async_to_sync
50+
51+
async_to_sync(channel_layer.group_add)(
52+
'ui', self.channel_name
53+
)
54+
55+
def disconnect(self, close_code):
56+
from channels.layers import get_channel_layer
57+
58+
channel_layer = get_channel_layer()
59+
if channel_layer is not None:
60+
from asgiref.sync import async_to_sync
61+
62+
async_to_sync(channel_layer.group_discard)(
63+
'ui', self.channel_name
64+
)
65+
66+
def ui_update(self, event):
67+
self.send(text_data=json.dumps(event.get('data', {})))

anthias_app/messaging.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
3+
logger = logging.getLogger(__name__)
4+
5+
6+
def send_to_viewer(command, data=None):
7+
try:
8+
from asgiref.sync import async_to_sync
9+
from channels.layers import get_channel_layer
10+
11+
channel_layer = get_channel_layer()
12+
if channel_layer is None:
13+
logger.warning('No channel layer available')
14+
return
15+
16+
msg = {'type': 'viewer.command', 'command': command}
17+
if data is not None:
18+
msg['data'] = data
19+
20+
async_to_sync(channel_layer.group_send)('viewer', msg)
21+
except Exception:
22+
logger.exception('Failed to send to viewer')
23+
24+
25+
def send_to_ui(data):
26+
try:
27+
from asgiref.sync import async_to_sync
28+
from channels.layers import get_channel_layer
29+
30+
channel_layer = get_channel_layer()
31+
if channel_layer is None:
32+
return
33+
34+
async_to_sync(channel_layer.group_send)(
35+
'ui', {'type': 'ui.update', 'data': data}
36+
)
37+
except Exception:
38+
logger.exception('Failed to send to UI')

anthias_app/tasks.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import logging
2+
import threading
3+
from os import getenv, path
4+
5+
import sh
6+
from tenacity import Retrying, stop_after_attempt, wait_fixed
7+
8+
from lib import diagnostics
9+
from lib.utils import (
10+
is_balena_app,
11+
reboot_via_balena_supervisor,
12+
shutdown_via_balena_supervisor,
13+
)
14+
15+
logger = logging.getLogger(__name__)
16+
17+
_display_power = None
18+
_scheduler_started = False
19+
_scheduler_lock = threading.Lock()
20+
21+
22+
def get_display_power_value():
23+
return _display_power
24+
25+
26+
def _update_display_power():
27+
global _display_power
28+
try:
29+
_display_power = diagnostics.get_display_power()
30+
except Exception:
31+
logger.exception('Failed to get display power')
32+
33+
34+
def cleanup():
35+
try:
36+
asset_dir = path.join(getenv('HOME', ''), 'screenly_assets')
37+
if path.isdir(asset_dir):
38+
sh.find(asset_dir, '-name', '*.tmp', '-delete')
39+
except Exception:
40+
logger.exception('Cleanup failed')
41+
42+
43+
def reboot_anthias():
44+
if is_balena_app():
45+
for attempt in Retrying(
46+
stop=stop_after_attempt(5),
47+
wait=wait_fixed(1),
48+
):
49+
with attempt:
50+
reboot_via_balena_supervisor()
51+
else:
52+
from subprocess import call
53+
54+
call(['reboot'])
55+
56+
57+
def shutdown_anthias():
58+
if is_balena_app():
59+
for attempt in Retrying(
60+
stop=stop_after_attempt(5),
61+
wait=wait_fixed(1),
62+
):
63+
with attempt:
64+
shutdown_via_balena_supervisor()
65+
else:
66+
from subprocess import call
67+
68+
call(['shutdown', '-h', 'now'])
69+
70+
71+
def _run_periodic(func, interval_seconds, name):
72+
def loop():
73+
while True:
74+
try:
75+
func()
76+
except Exception:
77+
logger.exception('Periodic task %s failed', name)
78+
threading.Event().wait(interval_seconds)
79+
80+
t = threading.Thread(target=loop, name=name, daemon=True)
81+
t.start()
82+
return t
83+
84+
85+
def start_background_scheduler():
86+
global _scheduler_started
87+
with _scheduler_lock:
88+
if _scheduler_started:
89+
return
90+
_scheduler_started = True
91+
92+
logger.info('Starting background task scheduler')
93+
_run_periodic(cleanup, 3600, 'cleanup')
94+
_run_periodic(_update_display_power, 300, 'display_power')

0 commit comments

Comments
 (0)