Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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