Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
4 changes: 2 additions & 2 deletions .github/workflows/build-balena-disk-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,8 @@ jobs:

# `balena deploy` (vs `balena push`) points the fleet at the
# existing <short-hash>-<board> GHCR images — no remote build.
# bin/balena_ota_deploy.sh owns the fleet mapping, compose render
# (incl. the pi5/x86 vchiq strip), version stamping, and retry;
# bin/balena_ota_deploy.sh owns the fleet mapping, compose render,
# version stamping, and retry;
# the manual deploy hook (deploy-balena-manual.yaml) calls the same
# script. `${TAG#v}` strips the leading `v` so the release tag
# (v2026.05.1) becomes the raw CalVer the script normalizes and
Expand Down
10 changes: 3 additions & 7 deletions bin/balena_ota_deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,9 @@ export BOARD
bin/render_balena_yml.sh balena-deploy "$RELEASE_VERSION"
envsubst < docker-compose.balena.yml.tmpl > balena-deploy/docker-compose.yml

# Pi 5, x86 and non-Pi arm64 SBCs (the rockpi4 fleet's images) don't
# expose /dev/vchiq; strip the bind mount. ($BOARD is already
# rewritten to arm64 for the rockpi4 fleet above.)
if [[ "$BOARD" =~ ^(pi5|x86|arm64)$ ]]; then
sed -i '/devices:/ {N; /\n.*\/dev\/vchiq:\/dev\/vchiq/d}' \
balena-deploy/docker-compose.yml
fi
# The /dev/vchiq strip that used to live here is gone: the template no
# longer bind-mounts it. It existed only for libcec, which has been
# replaced by the kernel CEC uABI.

# Wrapped in a 3-attempt retry because balena cloud routinely
# 5xx/ESOCKETTIMEDOUTs the upload step.
Expand Down
15 changes: 4 additions & 11 deletions bin/deploy_to_balena.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,10 @@ function prepare_balena_file() {
cat docker-compose.balena.yml.tmpl | \
envsubst > balena-deploy/docker-compose.yml

# Pi 5, x86 and non-Pi arm64 SBCs (the rockpi4 fleet's images)
# don't expose /dev/vchiq; strip the bind mount.
if [[ $BOARD =~ ^(pi5|x86|arm64)$ ]]; then
sed -i '/devices:/ {N; /\n.*\/dev\/vchiq:\/dev\/vchiq/d}' \
balena-deploy/docker-compose.yml
fi
# The /dev/vchiq strip that used to live here is gone: the template
# no longer bind-mounts it. It existed only for libcec, which has
# been replaced by the kernel CEC uABI, so there is no longer a
# board-specific device list to patch up here.
}

if ! balena whoami; then
Expand All @@ -136,10 +134,5 @@ else
cat docker-compose.balena.dev.yml.tmpl | \
envsubst > docker-compose.yml

if [[ $BOARD =~ ^(pi5|x86)$ ]]; then
sed -i '/devices:/ {N; /\n.*\/dev\/vchiq:\/dev\/vchiq/d}' \
docker-compose.yml
fi

balena push $FLEET
fi
44 changes: 18 additions & 26 deletions bin/upgrade_containers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,32 +158,24 @@ cat /home/${USER}/anthias/docker-compose.yml.tmpl \
| envsubst \
> /home/${USER}/anthias/docker-compose.yml

# CEC device routing. Pi 1-4 reaches libcec via /dev/vchiq
# (closed-firmware VideoCore IV), which is what the template's
# `devices:` block bind-mounts. Pi 5 and mainline-KMS x86/arm64 boards
# expose v4l2 CEC adapters at /dev/cec0 instead (Pi 5 also exposes
# /dev/cec1 for the second HDMI output, so we map both). docker
# compose's `devices:` fails container start if a listed host node is
# missing, so we surgically rewrite the rendered mount per device
# type — and on x86/arm64 we only swap in /dev/cec0 if the host
# actually has it (a box without an HDMI-CEC adapter keeps the
# pre-fix behaviour of dropping the bind mount entirely). Fixes
# the "CEC error" toast on Pi 5 reported in issue #2863.
case "$DEVICE_TYPE" in
pi5)
sed -i 's|^\([[:space:]]*\)- "/dev/vchiq:/dev/vchiq"$|\1- "/dev/cec0:/dev/cec0"\n\1- "/dev/cec1:/dev/cec1"|' \
/home/${USER}/anthias/docker-compose.yml
;;
x86|arm64)
if [ -e /dev/cec0 ]; then
sed -i 's|/dev/vchiq:/dev/vchiq|/dev/cec0:/dev/cec0|g' \
/home/${USER}/anthias/docker-compose.yml
else
sed -i '/devices:/ {N; /\n.*\/dev\/vchiq:\/dev\/vchiq/d}' \
/home/${USER}/anthias/docker-compose.yml
fi
;;
esac
# No CEC device passthrough is needed, on any board.
#
# HDMI-CEC is driven by the viewer container, which is `privileged: true`
# in every compose template and therefore already sees every /dev/cec*
# node. anthias-server and anthias-celery ask it over the Redis command
# bus (see src/anthias_server/lib/cec_client.py).
#
# An earlier iteration of this script enumerated the host's adapters here
# and generated a compose override for server/celery. That works on this
# install path but is impossible on balena — its compose file is baked
# into the release from a workstation, nothing on-device can enumerate
# the host, and a statically listed node that turns out to be absent
# stops the container from starting. Routing through the viewer supports
# both deployments with no device wiring and no OTA upgrade risk.
#
# A stale override from that iteration is removed so it cannot keep
# pinning nodes that the containers no longer need.
rm -f /home/${USER}/anthias/docker-compose.cec.override.yml

COMPOSE_FILES=(-f /home/${USER}/anthias/docker-compose.yml)
SSL_OVERRIDE=/home/${USER}/anthias/docker-compose.ssl.override.yml
Expand Down
34 changes: 34 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,37 @@ def _seed_singleton(cls: Any) -> None:

ViewerPublisher.INSTANCE = None
ReplyCollector.INSTANCE = None


@pytest.fixture
def _isolated_settings_conf(tmp_path: Any) -> Any:
"""Redirect the settings singleton's config file to a per-test temp
path so ``settings.save()`` writes — whether direct or through the
``settings_save`` view — never touch the real
``~/.anthias/anthias.conf``.

Without this, a settings-mutating test leaks its posted values onto
the shared on-device config and silently breaks later tests that
read defaults back from it. Concretely, the integration suite's
``test_add_asset_via_url`` asserts a new asset inherits
``default_duration``; it fails with ``assert 10 == 0`` when a
sibling here has already persisted a different value to disk (only
when both suites run against the same ``/data`` volume — CI dodges
it by isolating the two jobs). Generalises the same conf_file
redirect already used by ``_reset_review_cta``.
"""
from anthias_server.settings import settings

original_conf_file = settings.conf_file
try:
# Inside the try so a failure in the reassignment or the initial
# save() can't leave conf_file pointed at the temp path for the
# rest of the session — the finally always restores it.
settings.conf_file = str(tmp_path / 'anthias.conf')
settings.save()
yield
finally:
# Point back at the real config and reload so the singleton's
# in-memory state is restored for any later test.
settings.conf_file = original_conf_file
settings.load()
26 changes: 22 additions & 4 deletions docker-compose.balena.dev.yml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,17 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
devices:
- "/dev/vchiq:/dev/vchiq"
# No `devices:` here, and none needed. HDMI-CEC is driven by the
# anthias-viewer container, which is privileged and therefore
# already sees every /dev/cec* node; this service asks it over the
# Redis command bus (src/anthias_server/lib/cec_client.py).
#
# That indirection is what makes CEC work on balena at all. The
# compose file here is baked into the release from a workstation,
# nothing on-device can enumerate the host's adapters, and a
# statically listed node that turns out to be absent would stop the
# container from starting. /dev/vchiq used to be listed for libcec,
# which is gone.
restart: always
volumes:
- resin-data:/data
Expand Down Expand Up @@ -69,8 +78,17 @@ services:
- HOME=/data
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
devices:
- "/dev/vchiq:/dev/vchiq"
# No `devices:` here, and none needed. HDMI-CEC is driven by the
# anthias-viewer container, which is privileged and therefore
# already sees every /dev/cec* node; this service asks it over the
# Redis command bus (src/anthias_server/lib/cec_client.py).
#
# That indirection is what makes CEC work on balena at all. The
# compose file here is baked into the release from a workstation,
# nothing on-device can enumerate the host's adapters, and a
# statically listed node that turns out to be absent would stop the
# container from starting. /dev/vchiq used to be listed for libcec,
# which is gone.
restart: always
volumes:
- resin-data:/data
Expand Down
26 changes: 22 additions & 4 deletions docker-compose.balena.yml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ services:
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
devices:
- "/dev/vchiq:/dev/vchiq"
# No `devices:` here, and none needed. HDMI-CEC is driven by the
# anthias-viewer container, which is privileged and therefore
# already sees every /dev/cec* node; this service asks it over the
# Redis command bus (src/anthias_server/lib/cec_client.py).
#
# That indirection is what makes CEC work on balena at all. The
# compose file here is baked into the release from a workstation,
# nothing on-device can enumerate the host's adapters, and a
# statically listed node that turns out to be absent would stop the
# container from starting. /dev/vchiq used to be listed for libcec,
# which is gone.
restart: always
volumes:
- resin-data:/data
Expand Down Expand Up @@ -67,8 +76,17 @@ services:
- HOME=/data
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
devices:
- "/dev/vchiq:/dev/vchiq"
# No `devices:` here, and none needed. HDMI-CEC is driven by the
# anthias-viewer container, which is privileged and therefore
# already sees every /dev/cec* node; this service asks it over the
# Redis command bus (src/anthias_server/lib/cec_client.py).
#
# That indirection is what makes CEC work on balena at all. The
# compose file here is baked into the release from a workstation,
# nothing on-device can enumerate the host's adapters, and a
# statically listed node that turns out to be absent would stop the
# container from starting. /dev/vchiq used to be listed for libcec,
# which is gone.
restart: always
volumes:
- resin-data:/data
Expand Down
14 changes: 10 additions & 4 deletions docker-compose.yml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ services:
depends_on:
redis:
condition: service_healthy
devices:
- "/dev/vchiq:/dev/vchiq"
# No `devices:` here, deliberately. This service needs no hardware
# access at all: HDMI-CEC is driven by the anthias-viewer container
# (privileged, so it already sees every /dev/cec*) and reached over
# the Redis command bus — see src/anthias_server/lib/cec_client.py.
# /dev/vchiq used to be listed for libcec's display-power probe,
# which is gone.
restart: always
volumes:
- resin-data:/data
Expand Down Expand Up @@ -170,8 +174,10 @@ services:
- http_proxy=${http_proxy}
- https_proxy=${https_proxy}
- no_proxy=${no_proxy}
devices:
- "/dev/vchiq:/dev/vchiq"
# See the note on anthias-server: no hardware access needed. Celery
# runs the periodic display-power query and the schedule, but it
# asks anthias-viewer to do the CEC work rather than opening the
# devices itself.
restart: always
volumes:
- resin-data:/data
Expand Down
7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ docker-image-builder = [
"python-on-whales==0.81.0",
]
server = [
"cec==0.2.8",
"celery==5.6.3",
"certifi==2026.7.22",
"channels==4.3.2",
Expand Down Expand Up @@ -92,7 +91,6 @@ server = [
"yt-dlp>=2026.7.4",
]
viewer = [
"cec==0.2.8",
"certifi==2026.7.22",
"Django==5.2.14",
"Jinja2==3.1.6",
Expand Down Expand Up @@ -141,7 +139,7 @@ test = [
# Used by the python-mypy CI job. The django-stubs plugin imports
# anthias_server.django_project.settings to introspect the app registry, so we need the
# runtime deps that settings touches — but not the heavy native-extension
# deps from the server group (cec, netifaces, etc.). We also include
# deps from the server group (netifaces, etc.). We also include
# docker-image-builder so its tools (pygit2, python_on_whales) resolve.
mypy = [
{ include-group = "dev-host" },
Expand All @@ -158,7 +156,7 @@ mypy = [
# to type-check the upload-time normalisation pipeline.
# pillow-heif is intentionally not pinned here — it's listed in
# tool.mypy.overrides above so its missing-import error is
# ignored, mirroring the cec / pydbus / sh pattern.
# ignored, mirroring the pydbus / sh pattern.
"Pillow==12.3.0",
"pytz==2026.3.post1",
"sentry-sdk==2.66.1",
Expand Down Expand Up @@ -252,7 +250,6 @@ exclude = [
# `channels` itself is covered by partial PEP 561 stubs in stubs/channels-stubs/;
# only the helpers we actually call are typed there.
module = [
"cec",
"channels_redis.*",
"gi",
"gi.*",
Expand Down
58 changes: 58 additions & 0 deletions src/anthias_server/api/serializers/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@
validate_asset_headers,
)
from anthias_server.django_project.settings import is_valid_time_zone
from anthias_server.lib import display_power


def _validate_hhmm(value: str) -> str:
"""Normalise an ``HH:MM`` (or ``HH:MM:SS``) time to ``HH:MM``.

Rejects rather than ignores a malformed value. The HTML form keeps
the previous setting and shows a toast, but an API client gets a 400:
silently discarding a field it explicitly sent would be worse.
"""
parsed = display_power.parse_hhmm(value)
if parsed is None:
raise serializers.ValidationError(
f'Invalid time {value!r}: expected HH:MM.'
)
return parsed.strftime('%H:%M')


def _normalise_play_days(value: list[int]) -> list[int]:
Expand Down Expand Up @@ -421,6 +437,14 @@ class DeviceSettingsSerializerV2(Serializer[Any]):
# on the value being one of {0, 90, 180, 270} when reading too.
screen_rotation = ChoiceField(choices=SCREEN_ROTATION_CHOICES)
username = CharField()
# Scheduled display power. Times are 'HH:MM' in the device's
# configured timezone; days is a comma-separated list of Python
# weekday numbers (Monday=0) naming the days an on-period *begins*
# — which is what governs a schedule that wraps past midnight.
display_power_schedule_enabled = BooleanField()
display_power_on_time = CharField()
display_power_off_time = CharField()
display_power_days = CharField(allow_blank=True)


class UpdateDeviceSettingsSerializerV2(Serializer[Any]):
Expand Down Expand Up @@ -464,6 +488,40 @@ class UpdateDeviceSettingsSerializerV2(Serializer[Any]):
],
)
current_password = CharField(required=False, allow_blank=True)
# Scheduled display power — mirrors the HTML form path in
# anthias_app.views._apply_display_power_schedule_settings.
display_power_schedule_enabled = BooleanField(required=False)
display_power_on_time = CharField(required=False)
display_power_off_time = CharField(required=False)
display_power_days = CharField(required=False, allow_blank=True)

def validate_display_power_on_time(self, value: str) -> str:
return _validate_hhmm(value)

def validate_display_power_off_time(self, value: str) -> str:
return _validate_hhmm(value)

def validate_display_power_days(self, value: str) -> str:
"""Normalise the weekday list.

Rejected rather than silently coerced: unlike the HTML form —
where an empty checkbox set legitimately means "every day" — an
API client that sends garbage has made a mistake and should be
told, not have it reinterpreted.
"""
raw = (value or '').strip()
if not raw:
return display_power.ALL_DAYS
days = []
for token in raw.split(','):
token = token.strip()
if not token.isdigit() or not 0 <= int(token) <= 6:
raise serializers.ValidationError(
f'Invalid weekday {token!r}: expected 0-6 '
'(Monday=0), comma-separated.'
)
days.append(int(token))
return ','.join(str(d) for d in sorted(set(days)))

def validate_timezone(self, value: str) -> str:
value = (value or '').strip()
Expand Down
Loading