From 2f8cdcce5a1f0465ddfa511ac26d14d1e6899a0b Mon Sep 17 00:00:00 2001 From: vpetersson-bot <307551610+vpetersson-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:21:32 +0000 Subject: [PATCH 01/14] feat(cec): rewrite HDMI-CEC on the kernel uABI, add a display schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces libcec with the kernel CEC API (/dev/cec*), fixes device passthrough, and adds a scheduled display on/off. Mechanism - New lib/cec.py talks to /dev/cec* via ioctl from pure stdlib: no subprocess, no native library, no core-dump workaround. - libcec cannot select an adapter — it enumerates the kernel adapter as com port 'Linux' and treats /dev/cecN as a Pulse-Eight serial port, so `cec-client ... /dev/cec1` fails to open at all (11.07s of retries, measured). The kernel API addresses each node directly. - Every operation fans out across all adapters with a live link, so a device with two monitors attached does not leave the second one lit. - Drops cec==0.2.8, libcec7, cec-utils and libcec-dev. Passthrough - Which /dev/cec* exist is a host property, not a board property. The board-cased sed in upgrade_containers.sh left pi2/pi3/pi3-64/pi4-64 on a useless /dev/vchiq; enumerate the host instead and emit a generated docker-compose.cec.override.yml. - Removes the now-dead vchiq mounts and their per-board strip logic. Schedule - New lib/display_power.py plus a per-minute, edge-triggered beat task. - Falls back to the viewer's blank/unblank when no CEC display answers, so it works on plain monitors too. Notable fixes found on hardware - A failed logical-address claim surfaced as 'no peer', reintroducing the exact conflation of GH #3267; failures are now a distinct ERROR state. - The kernel returns EBUSY when configuring an already-configured adapter, and libcec leaves every adapter configured after it exits, so the claim now always clears first. Without this, CEC would have failed on every device upgrading from the libcec build. Co-Authored-By: Claude Opus 5 (1M context) --- .../workflows/build-balena-disk-image.yaml | 4 +- bin/balena_ota_deploy.sh | 10 +- bin/deploy_to_balena.sh | 15 +- bin/upgrade_containers.sh | 79 +- docker-compose.balena.dev.yml.tmpl | 22 +- docker-compose.balena.yml.tmpl | 22 +- docker-compose.yml.tmpl | 15 +- pyproject.toml | 7 +- .../api/tests/test_v2_endpoints.py | 6 +- src/anthias_server/api/views/mixins.py | 4 +- src/anthias_server/app/page_context.py | 32 +- .../app/templates/settings.html | 67 ++ src/anthias_server/app/views.py | 30 + src/anthias_server/celery_tasks.py | 126 +++- src/anthias_server/lib/cec.py | 673 ++++++++++++++++++ src/anthias_server/lib/diagnostics.py | 429 +++-------- src/anthias_server/lib/display_power.py | 138 ++++ src/anthias_server/settings.py | 10 + tests/test_app.py | 64 +- tests/test_cec.py | 382 ++++++++++ tests/test_celery_tasks.py | 136 +++- tests/test_diagnostics.py | 411 +++-------- tests/test_display_power.py | 255 +++++++ tests/test_template_views.py | 89 +++ tools/image_builder/__main__.py | 20 +- tools/image_builder/utils.py | 8 +- uv.lock | 12 - 27 files changed, 2272 insertions(+), 794 deletions(-) create mode 100644 src/anthias_server/lib/cec.py create mode 100644 src/anthias_server/lib/display_power.py create mode 100644 tests/test_cec.py create mode 100644 tests/test_display_power.py diff --git a/.github/workflows/build-balena-disk-image.yaml b/.github/workflows/build-balena-disk-image.yaml index 2f7dae35d..13e535fda 100644 --- a/.github/workflows/build-balena-disk-image.yaml +++ b/.github/workflows/build-balena-disk-image.yaml @@ -258,8 +258,8 @@ jobs: # `balena deploy` (vs `balena push`) points the fleet at the # existing - 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 diff --git a/bin/balena_ota_deploy.sh b/bin/balena_ota_deploy.sh index 8b9da1492..305d92f1d 100755 --- a/bin/balena_ota_deploy.sh +++ b/bin/balena_ota_deploy.sh @@ -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. diff --git a/bin/deploy_to_balena.sh b/bin/deploy_to_balena.sh index 850a5bf74..5a565ee70 100755 --- a/bin/deploy_to_balena.sh +++ b/bin/deploy_to_balena.sh @@ -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 @@ -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 diff --git a/bin/upgrade_containers.sh b/bin/upgrade_containers.sh index d77e0bc00..760c79b4d 100755 --- a/bin/upgrade_containers.sh +++ b/bin/upgrade_containers.sh @@ -158,34 +158,61 @@ 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 +# CEC device passthrough. +# +# `docker compose` refuses to start a container whose listed host node +# is missing, so the device list has to match the host exactly. It used +# to be derived from $DEVICE_TYPE, which was wrong in both directions: +# pi2/pi3/pi3-64/pi4-64 fell through the case entirely and kept +# /dev/vchiq (a node libcec cannot use on a mainline-KMS kernel, and +# which the current implementation does not use at all), while pi5 got +# a hardcoded /dev/cec0 + /dev/cec1 (GH #3267). +# +# Which adapters exist is a property of the host, not of the board +# name, so enumerate them instead of guessing. Measured across the +# testbed fleet: the nodes are created by the SoC's HDMI driver and are +# present whether or not anything is plugged in (a Pi 3 A+ with a +# disconnected HDMI still has /dev/cec0), and a board with two HDMI +# outputs has one node per output (Pi 4 and Pi 5 both expose +# /dev/cec0 + /dev/cec1). x86 has none at all unless an add-on adapter +# is fitted. +# +# The result goes in a separate override file rather than being sed'd +# into the rendered compose: it keeps the generated main file free of +# host-specific surgery, makes "what CEC does this device have" a +# single greppable artifact, and means a bad enumeration degrades to +# "no CEC" instead of "containers will not start". +CEC_OVERRIDE=/home/${USER}/anthias/docker-compose.cec.override.yml +CEC_NODES=() +for node in /dev/cec[0-9]*; do + [ -c "$node" ] && CEC_NODES+=("$node") +done + +if [ ${#CEC_NODES[@]} -gt 0 ]; then + echo "Passing ${#CEC_NODES[@]} CEC device(s) through: ${CEC_NODES[*]}" + { + echo "# Generated by bin/upgrade_containers.sh — do not edit." + echo "# Lists the CEC adapters found on this host at upgrade time." + echo "services:" + for service in anthias-server anthias-celery; do + echo " ${service}:" + echo " devices:" + for node in "${CEC_NODES[@]}"; do + echo " - \"${node}:${node}\"" + done + done + } > "$CEC_OVERRIDE" +else + # No CEC hardware. Remove any override left by a previous run so a + # node that has since disappeared cannot block container start. + echo "No CEC devices found; display power will report 'not available'" + rm -f "$CEC_OVERRIDE" +fi COMPOSE_FILES=(-f /home/${USER}/anthias/docker-compose.yml) +if [[ -f "$CEC_OVERRIDE" ]]; then + COMPOSE_FILES+=(-f "$CEC_OVERRIDE") +fi SSL_OVERRIDE=/home/${USER}/anthias/docker-compose.ssl.override.yml if [[ -f "$SSL_OVERRIDE" ]]; then COMPOSE_FILES+=(-f "$SSL_OVERRIDE") diff --git a/docker-compose.balena.dev.yml.tmpl b/docker-compose.balena.dev.yml.tmpl index d5c7af99f..4880b2a95 100644 --- a/docker-compose.balena.dev.yml.tmpl +++ b/docker-compose.balena.dev.yml.tmpl @@ -16,8 +16,15 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: - redis - devices: - - "/dev/vchiq:/dev/vchiq" + # No `devices:` here. /dev/vchiq existed solely for libcec's + # display-power probe, which is gone. CEC passthrough is NOT wired + # up on balena: unlike the docker-compose install path there is no + # on-device step that can enumerate the host's /dev/cec* nodes, and + # a statically-listed node that turns out to be absent stops the + # container from starting at all. Balena fleets therefore report + # display power as "not available" — which is what they already did + # in practice, since the deploy scripts stripped the vchiq mount on + # pi5/x86/arm64 and libcec could not use it on the other boards. restart: always volumes: - resin-data:/data @@ -69,8 +76,15 @@ 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. /dev/vchiq existed solely for libcec's + # display-power probe, which is gone. CEC passthrough is NOT wired + # up on balena: unlike the docker-compose install path there is no + # on-device step that can enumerate the host's /dev/cec* nodes, and + # a statically-listed node that turns out to be absent stops the + # container from starting at all. Balena fleets therefore report + # display power as "not available" — which is what they already did + # in practice, since the deploy scripts stripped the vchiq mount on + # pi5/x86/arm64 and libcec could not use it on the other boards. restart: always volumes: - resin-data:/data diff --git a/docker-compose.balena.yml.tmpl b/docker-compose.balena.yml.tmpl index d0fd5f078..c63030957 100644 --- a/docker-compose.balena.yml.tmpl +++ b/docker-compose.balena.yml.tmpl @@ -13,8 +13,15 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: - redis - devices: - - "/dev/vchiq:/dev/vchiq" + # No `devices:` here. /dev/vchiq existed solely for libcec's + # display-power probe, which is gone. CEC passthrough is NOT wired + # up on balena: unlike the docker-compose install path there is no + # on-device step that can enumerate the host's /dev/cec* nodes, and + # a statically-listed node that turns out to be absent stops the + # container from starting at all. Balena fleets therefore report + # display power as "not available" — which is what they already did + # in practice, since the deploy scripts stripped the vchiq mount on + # pi5/x86/arm64 and libcec could not use it on the other boards. restart: always volumes: - resin-data:/data @@ -67,8 +74,15 @@ 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. /dev/vchiq existed solely for libcec's + # display-power probe, which is gone. CEC passthrough is NOT wired + # up on balena: unlike the docker-compose install path there is no + # on-device step that can enumerate the host's /dev/cec* nodes, and + # a statically-listed node that turns out to be absent stops the + # container from starting at all. Balena fleets therefore report + # display power as "not available" — which is what they already did + # in practice, since the deploy scripts stripped the vchiq mount on + # pi5/x86/arm64 and libcec could not use it on the other boards. restart: always volumes: - resin-data:/data diff --git a/docker-compose.yml.tmpl b/docker-compose.yml.tmpl index 676ce7b63..d8b6c1e1f 100644 --- a/docker-compose.yml.tmpl +++ b/docker-compose.yml.tmpl @@ -37,8 +37,13 @@ services: depends_on: redis: condition: service_healthy - devices: - - "/dev/vchiq:/dev/vchiq" + # No `devices:` here on purpose. The only device this service ever + # needed was /dev/vchiq, and that existed solely for libcec's + # display-power probe — which is gone (CEC now speaks the kernel + # uABI directly). CEC nodes are instead supplied by the generated + # docker-compose.cec.override.yml, because which /dev/cec* exist is + # a property of the host, not of the board name. See + # bin/upgrade_containers.sh. restart: always volumes: - resin-data:/data @@ -170,8 +175,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: CEC device nodes come from the + # generated docker-compose.cec.override.yml, not from here. Celery + # is the service that actually runs the periodic display-power + # query, so it needs the same nodes the server does. restart: always volumes: - resin-data:/data diff --git a/pyproject.toml b/pyproject.toml index e284ddc7b..da50f44f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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", @@ -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" }, @@ -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", @@ -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.*", diff --git a/src/anthias_server/api/tests/test_v2_endpoints.py b/src/anthias_server/api/tests/test_v2_endpoints.py index c415a3515..a8b0b078a 100644 --- a/src/anthias_server/api/tests/test_v2_endpoints.py +++ b/src/anthias_server/api/tests/test_v2_endpoints.py @@ -688,9 +688,9 @@ def test_display_power_returns_503_when_no_cec_adapter( _cec_available_mock: Any, api_client: APIClient, ) -> None: - """The endpoint must fail fast (no 10 s subprocess) when neither - /dev/cec0 nor /dev/vchiq exists. 503 telegraphs 'this server lacks - the hardware to satisfy the request' more accurately than 502.""" + """The endpoint must fail fast when the device has no CEC adapter + at all. 503 telegraphs 'this server lacks the hardware to satisfy + the request' more accurately than 502.""" response = api_client.post( reverse('api:display_power_v2', kwargs={'state': 'on'}) ) diff --git a/src/anthias_server/api/views/mixins.py b/src/anthias_server/api/views/mixins.py index c8dfb78a8..17b4775a5 100644 --- a/src/anthias_server/api/views/mixins.py +++ b/src/anthias_server/api/views/mixins.py @@ -236,8 +236,8 @@ def post(self, request: Request, state: str) -> Response: {'message': 'Invalid display state.'}, status=status.HTTP_400_BAD_REQUEST, ) - # No /dev/cec0 or /dev/vchiq — fail fast with 503 rather than - # spawning a 10 s libcec subprocess that's guaranteed to error. + # No CEC adapter on this device — fail fast with 503 rather + # than attempting a transmit that cannot succeed. if not diagnostics.cec_available(): return Response( {'message': 'No HDMI-CEC adapter detected on this device.'}, diff --git a/src/anthias_server/app/page_context.py b/src/anthias_server/app/page_context.py index 93f056fc7..3bb7f445a 100644 --- a/src/anthias_server/app/page_context.py +++ b/src/anthias_server/app/page_context.py @@ -218,6 +218,18 @@ def _load_bar(value: float) -> dict[str, float | str]: ('yyyy.mm.dd', 'year.month.day'), ) +# Python weekday numbering (Monday=0 ... Sunday=6), matching what +# lib/display_power.py parses and what datetime.weekday() returns. +_WEEKDAY_OPTIONS = ( + (0, 'Mon'), + (1, 'Tue'), + (2, 'Wed'), + (3, 'Thu'), + (4, 'Fri'), + (5, 'Sat'), + (6, 'Sun'), +) + @functools.lru_cache(maxsize=1) def _timezone_options() -> tuple[tuple[str, str], ...]: @@ -295,9 +307,25 @@ def device_settings() -> dict[str, Any]: 'date_format_options': _DATE_FORMAT_OPTIONS, 'timezone_options': _timezone_options(), # Render-time gate for the experimental CEC display-power - # buttons; cec_available() only stats device nodes, so it's - # cheap enough to call on every settings render. + # buttons. cec_available() enumerates /dev/cec* and reads each + # adapter's capabilities — measured at 0.0-0.1ms per adapter on + # every board in the testbed fleet, so it stays cheap enough to + # call on every settings render. 'cec_available': diagnostics.cec_available(), + # The schedule is NOT gated on cec_available(): when no CEC + # display answers it falls back to the viewer's local blanking, + # so it is useful on boards with a plain monitor too. + 'display_power_schedule_enabled': settings[ + 'display_power_schedule_enabled' + ], + 'display_power_on_time': settings['display_power_on_time'], + 'display_power_off_time': settings['display_power_off_time'], + 'display_power_days': [ + int(d) + for d in str(settings['display_power_days']).split(',') + if d.strip().isdigit() + ], + 'weekday_options': _WEEKDAY_OPTIONS, } diff --git a/src/anthias_server/app/templates/settings.html b/src/anthias_server/app/templates/settings.html index ed4846c20..c760f1327 100644 --- a/src/anthias_server/app/templates/settings.html +++ b/src/anthias_server/app/templates/settings.html @@ -151,6 +151,73 @@

Authentication

+ {% comment %} + Scheduled display power. Deliberately NOT gated on cec_available: + when no CEC display answers, the schedule falls back to the + viewer's local blanking, so it is useful on a plain monitor too. + {% endcomment %} +
+
+
+

Display schedule

+

+ Turn the screen off outside opening hours. Uses HDMI-CEC where the + display supports it, and blanks the screen locally where it does not. +

+
+
+ +
+
+ {% include "_settings_toggle.html" with name="display_power_schedule_enabled" label="Enable display schedule" hint="Switch the screen off outside the hours below." checked=display_power_schedule_enabled %} +
+ +
+
+
+ + +
+
+ + +
+
+ +

+ Active days. An overnight schedule (for example on at 18:00, off at + 06:00) belongs to the day it starts. +

+
+ {% for value, label in weekday_options %} +
+ + +
+ {% endfor %} +
+
+
+
+