Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ updates:
npm:
patterns:
- "*"
- package-ecosystem: "pip"
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "daily"
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/generate-openapi-schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ jobs:
run: |
docker compose up -d

- name: Wait for server to be ready
run: |
for i in $(seq 1 30); do
if docker compose exec anthias-server python -c "import django" 2>/dev/null; then
echo "Server is ready"
break
fi
echo "Waiting for server... ($i/30)"
sleep 2
done

- name: Generate OpenAPI Schema
run: |
docker compose exec anthias-server \
Expand Down
25 changes: 0 additions & 25 deletions .prettierrc

This file was deleted.

2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.11
3.13
76 changes: 35 additions & 41 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,21 @@ Anthias is an open-source digital signage platform for Raspberry Pi and x86 PCs

## Architecture

Anthias runs as a set of Docker containers:
Anthias runs as 2 Docker containers:

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

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`.
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`.

### Key Directories

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

```bash
./bin/start_development_server.sh # Start full dev environment (Docker)
docker compose -f docker-compose.dev.yml down # Stop dev server
docker compose -f docker-compose.dev.yml up --build # Start dev environment
docker compose -f docker-compose.dev.yml down # Stop dev server
# Web UI at http://localhost:8000
```

### Frontend (TypeScript/React)
### Python Dependencies

```bash
npm install
npm run dev # Webpack watch mode
npm run build # Production build
npm run lint:check # ESLint check
npm run lint:fix # ESLint fix
npm run format:check # Prettier check
npm run format:fix # Prettier fix
npm run test # Jest tests
```
All Python dependencies are managed via `pyproject.toml` dependency groups, with `uv.lock` for reproducible installs:

- `server` — Django web app (anthias-server)
- `viewer` — Viewer service
- `wifi-connect` — WiFi Connect service
- `dev` — Test utilities (mock, selenium, etc.)
- `test` — Combined group (includes server + dev + viewer)
- `host` — Host machine (Ansible, etc.)
- `local` — Local CLI tools
- `dev-host` — Ruff linter
- `docker-image-builder` — Docker image build tooling

Inside Docker:
```bash
docker compose -f docker-compose.dev.yml exec anthias-server npm run dev
uv lock # Regenerate lockfile after changing deps
uv sync --only-group <name> # Install a specific group
```

### Python Linting
Expand All @@ -74,10 +69,10 @@ uv run ruff check /path/to/file.py # Lint specific file

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

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

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

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

## API Versions
## API

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.
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/`.
10 changes: 10 additions & 0 deletions anthias_app/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import os

from django.apps import AppConfig


class AnthiasAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'anthias_app'

def ready(self):
if os.environ.get('RUN_MAIN') != 'true':
return

from anthias_app.tasks import start_background_scheduler

start_background_scheduler()
67 changes: 67 additions & 0 deletions anthias_app/consumers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import json
import logging

from channels.generic.websocket import WebsocketConsumer

logger = logging.getLogger(__name__)


class ViewerConsumer(WebsocketConsumer):
def connect(self) -> None:
from channels.layers import get_channel_layer

self.accept()
channel_layer = get_channel_layer()
if channel_layer is not None:
from asgiref.sync import async_to_sync

async_to_sync(channel_layer.group_add)(
'viewer', self.channel_name
)
logger.info('Viewer WebSocket connected')

def disconnect(self, close_code: int) -> None:
from channels.layers import get_channel_layer

channel_layer = get_channel_layer()
if channel_layer is not None:
from asgiref.sync import async_to_sync

async_to_sync(channel_layer.group_discard)(
'viewer', self.channel_name
)
logger.info('Viewer WebSocket disconnected')

def viewer_command(self, event: dict) -> None:
self.send(text_data=json.dumps({
'command': event['command'],
'data': event.get('data'),
}))


class UIConsumer(WebsocketConsumer):
def connect(self) -> None:
from channels.layers import get_channel_layer

self.accept()
channel_layer = get_channel_layer()
if channel_layer is not None:
from asgiref.sync import async_to_sync

async_to_sync(channel_layer.group_add)(
'ui', self.channel_name
)

def disconnect(self, close_code: int) -> None:
from channels.layers import get_channel_layer

channel_layer = get_channel_layer()
if channel_layer is not None:
from asgiref.sync import async_to_sync

async_to_sync(channel_layer.group_discard)(
'ui', self.channel_name
)

def ui_update(self, event: dict) -> None:
self.send(text_data=json.dumps(event.get('data', {})))
12 changes: 6 additions & 6 deletions anthias_app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from settings import settings


def template(request, template_name, context):
def template(request, template_name: str, context: dict):
"""
This is a helper function that is used to render a template
with some global context. This is used to avoid having to
Expand All @@ -33,11 +33,11 @@ def template(request, template_name, context):
return render(request, template_name, context)


def prepare_default_asset(**kwargs):
def prepare_default_asset(**kwargs) -> dict | None:
if kwargs['mimetype'] not in ['image', 'video', 'webpage']:
return

asset_id = 'default_{}'.format(uuid.uuid4().hex)
asset_id = f'default_{uuid.uuid4().hex}'
duration = (
int(get_video_duration(kwargs['uri']).total_seconds())
if 'video' == kwargs['mimetype']
Expand All @@ -60,7 +60,7 @@ def prepare_default_asset(**kwargs):
}


def add_default_assets():
def add_default_assets() -> None:
settings.load()

datetime_now = timezone.now()
Expand All @@ -75,7 +75,7 @@ def add_default_assets():
'.screenly/default_assets.yml',
)

with open(default_assets_yaml, 'r') as yaml_file:
with open(default_assets_yaml) as yaml_file:
default_assets = yaml.safe_load(yaml_file).get('assets')

for default_asset in default_assets:
Expand All @@ -92,7 +92,7 @@ def add_default_assets():
Asset.objects.create(**asset)


def remove_default_assets():
def remove_default_assets() -> None:
settings.load()

for asset in Asset.objects.all():
Expand Down
40 changes: 40 additions & 0 deletions anthias_app/messaging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import logging

logger = logging.getLogger(__name__)


def send_to_viewer(
command: str, data: str | None = None,
) -> None:
try:
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

channel_layer = get_channel_layer()
if channel_layer is None:
logger.warning('No channel layer available')
return

msg = {'type': 'viewer.command', 'command': command}
if data is not None:
msg['data'] = data

async_to_sync(channel_layer.group_send)('viewer', msg)
except Exception:
logger.exception('Failed to send to viewer')


def send_to_ui(data: dict) -> None:
try:
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

channel_layer = get_channel_layer()
if channel_layer is None:
return

async_to_sync(channel_layer.group_send)(
'ui', {'type': 'ui.update', 'data': data}
)
except Exception:
logger.exception('Failed to send to UI')
Loading
Loading