From c2ec63df18f52114d694f93f2d61637521481be9 Mon Sep 17 00:00:00 2001 From: Federico Pratto Date: Thu, 18 Jun 2026 13:05:05 -0300 Subject: [PATCH 1/3] fix(cec): support HDMI-CEC display power on Pi 4/5 second micro-HDMI port --- bin/upgrade_containers.sh | 42 +++++++++++++++---- src/anthias_server/lib/diagnostics.py | 6 ++- tests/test_app.py | 58 ++++++++++++++++++++++++--- tests/test_diagnostics.py | 11 +++++ 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/bin/upgrade_containers.sh b/bin/upgrade_containers.sh index 3c62cc2b6..b7c23de1d 100755 --- a/bin/upgrade_containers.sh +++ b/bin/upgrade_containers.sh @@ -163,19 +163,45 @@ cat /home/${USER}/anthias/docker-compose.yml.tmpl \ # 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' \ + pi4-64|pi5) + CEC_DEV="" + if command -v cec-ctl >/dev/null 2>&1; then + for DEV in /dev/cec0 /dev/cec1; do + [ -e "$DEV" ] || continue + PHYS_ADDR=$(cec-ctl -d "$DEV" --playback --logical-address 2>/dev/null \ + | grep "Physical Address" | awk -F: '{print $2}' | xargs) + if [ -n "$PHYS_ADDR" ] && [ "$PHYS_ADDR" != "f.f.f.f" ]; then + CEC_DEV="$DEV" + break + fi + done + fi + + if [ -n "$CEC_DEV" ]; then + # libcec solo prueba /dev/cec0 — no enumera /dev/cec1 + # aunque sea el puerto realmente conectado a la TV + # (confirmado en hardware: con solo /dev/cec1 montado con + # su propio nombre, cec.init() tira "No default adapter + # found"). Remapeamos el puerto que esté vivo al path fijo + # /dev/cec0 dentro del contenedor, sin importar a qué + # micro-HDMI físico corresponda en el host. + sed -i "s|^\([[:space:]]*\)- \"/dev/vchiq:/dev/vchiq\"\$|\1- \"$CEC_DEV:/dev/cec0\"|" \ /home/${USER}/anthias/docker-compose.yml else - sed -i '/devices:/ {N; /\n.*\/dev\/vchiq:\/dev\/vchiq/d}' \ + # cec-ctl no está en el host, o ningún puerto reportó + # dirección física (TV apagada durante el upgrade, por + # ejemplo): mantenemos el comportamiento anterior — + # montar los dos con su nombre real. Si el puerto vivo + # resulta ser /dev/cec1, va a seguir sin funcionar hasta + # el próximo upgrade que sí pueda detectarlo; es una + # limitación conocida de este fallback degradado. + 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 fi ;; + x86|arm64) + ... + ;; esac COMPOSE_FILES=(-f /home/${USER}/anthias/docker-compose.yml) diff --git a/src/anthias_server/lib/diagnostics.py b/src/anthias_server/lib/diagnostics.py index 81bd572ec..32e70a36c 100755 --- a/src/anthias_server/lib/diagnostics.py +++ b/src/anthias_server/lib/diagnostics.py @@ -175,7 +175,11 @@ def cec_available() -> bool: means the adapter *could* work, not that it will: the actual success/failure is surfaced by ``set_display_power``'s toast. """ - return os.path.exists('/dev/cec0') or os.path.exists('/dev/vchiq') + return ( + os.path.exists('/dev/cec0') + or os.path.exists('/dev/cec1') + or os.path.exists('/dev/vchiq') + ) def get_uptime() -> float: diff --git a/tests/test_app.py b/tests/test_app.py index 9f291af53..09dcd98d1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1865,11 +1865,13 @@ def test_skip_buttons_publish_correct_command( # 8. Display power (experimental, HDMI-CEC) — issue #2575 # --------------------------------------------------------------------------- # -# The section is gated on cec_available(), which stats /dev/cec0 and -# /dev/vchiq. Neither exists in the test container by default, so the -# section is hidden on every other settings test. To exercise the -# visible state we stub /dev/vchiq with a plain file before navigating -# and remove it on teardown. +# The section is gated on cec_available(), which stats /dev/cec0, +# /dev/cec1, and /dev/vchiq. The /dev/cec1 check covers Pi 4/5 hosts +# where the display is wired to the second micro-HDMI port (issue +# #2863). None of these exist in the test container by default, so +# the section is hidden on every other settings test. To exercise the +# visible state we stub one of them with a plain file before +# navigating and remove it on teardown. # Screenshot capture is OFF by default. The original PR (#2886) used # screenshots for a one-time UX review; running them on every CI cycle @@ -1921,6 +1923,32 @@ def cec_stub_device() -> Any: pass +@pytest.fixture +def cec1_stub_device() -> Any: + """Create a stub `/dev/cec1` so `diagnostics.cec_available()` + returns True via the second-HDMI-port path rather than + `/dev/vchiq`. Exercises the Pi 4/5 dual-micro-HDMI case where the + display is wired to the second port and only `/dev/cec1` exists + (issue #2863's actual failure mode). + """ + path = '/dev/cec1' + created = False + if not os.path.exists(path): + try: + open(path, 'w').close() + created = True + except OSError: + pytest.skip('cannot stub /dev/cec1 in this environment') + try: + yield path + finally: + if created: + try: + os.remove(path) + except FileNotFoundError: + pass + + @pytest.mark.integration @pytest.mark.django_db(transaction=True) def test_display_power_section_hidden_without_cec_adapter( @@ -1929,7 +1957,11 @@ def test_display_power_section_hidden_without_cec_adapter( """No /dev/cec0 or /dev/vchiq in the container by default — the experimental section must NOT render. Guards against accidentally surfacing CEC controls on x86 / non-CEC hardware.""" - if os.path.exists('/dev/vchiq') or os.path.exists('/dev/cec0'): + if ( + os.path.exists('/dev/vchiq') + or os.path.exists('/dev/cec0') + or os.path.exists('/dev/cec1') + ): pytest.skip('CEC device present; cannot test the hidden case') page.goto(SETTINGS_URL) expect( @@ -1975,6 +2007,20 @@ def test_display_power_section_visible_with_cec_adapter( ) +@pytest.mark.integration +@pytest.mark.django_db(transaction=True) +def test_display_power_section_visible_with_cec1_only( + reset_assets: None, page: Page, cec1_stub_device: str +) -> None: + """Pi 4/5 with the display on the second micro-HDMI port exposes + only /dev/cec1, not /dev/cec0 — the section must still render + instead of silently disappearing (issue #2863).""" + page.goto(SETTINGS_URL) + expect(page.get_by_role('heading', name='Display power')).to_be_visible() + expect(page.get_by_role('button', name='Turn display on')).to_be_visible() + expect(page.get_by_role('button', name='Turn display off')).to_be_visible() + + @pytest.mark.integration @pytest.mark.django_db(transaction=True) def test_display_power_button_click_surfaces_error_toast( diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 1335c37e0..34a95a004 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -289,6 +289,17 @@ def test_cec_available_true_when_cec0_present() -> None: assert diagnostics.cec_available() is True +def test_cec_available_true_when_only_cec1_present() -> None: + """Pi 4/5 displays wired to the second micro-HDMI port only + expose /dev/cec1 — the gate must not assume /dev/cec0 is the only + possible CEC node (the actual failure mode behind the + device-routing fix for issue #2863).""" + with mock.patch.object( + os.path, 'exists', side_effect=lambda p: p == '/dev/cec1' + ): + assert diagnostics.cec_available() is True + + def test_cec_available_true_when_vchiq_present() -> None: with mock.patch.object( os.path, 'exists', side_effect=lambda p: p == '/dev/vchiq' From f07d2ea27a53c2b9dac9431a19e36be062ed451d Mon Sep 17 00:00:00 2001 From: Federico Pratto Date: Thu, 18 Jun 2026 14:09:27 -0300 Subject: [PATCH 2/3] fix(cec): restore x86/arm64 branch, guard pi4-64 fallback mounts, drop mutating CEC probe - Restore the x86|arm64 case body that was accidentally replaced with a placeholder, breaking the upgrade on every non-Pi device. - Guard each /dev/cecN mount in the pi4-64|pi5 fallback path with [ -e ], since devices: entries fail container start if a listed host path doesn't exist and pi4-64 isn't confirmed to always expose both nodes. - Swap cec-ctl --playback for a bare invocation when probing physical address, avoiding the active CEC bus scan / logical-address claim that --playback (and --show-topology) trigger. - Translate inline comments to English for consistency with the rest of the file. --- bin/upgrade_containers.sh | 67 ++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/bin/upgrade_containers.sh b/bin/upgrade_containers.sh index b7c23de1d..b46a6352e 100755 --- a/bin/upgrade_containers.sh +++ b/bin/upgrade_containers.sh @@ -168,8 +168,8 @@ case "$DEVICE_TYPE" in if command -v cec-ctl >/dev/null 2>&1; then for DEV in /dev/cec0 /dev/cec1; do [ -e "$DEV" ] || continue - PHYS_ADDR=$(cec-ctl -d "$DEV" --playback --logical-address 2>/dev/null \ - | grep "Physical Address" | awk -F: '{print $2}' | xargs) + PHYS_ADDR=$(cec-ctl -d "$DEV" 2>/dev/null \ + | grep "Physical Address" | head -1 | awk -F: '{print $2}' | xargs) if [ -n "$PHYS_ADDR" ] && [ "$PHYS_ADDR" != "f.f.f.f" ]; then CEC_DEV="$DEV" break @@ -178,29 +178,58 @@ case "$DEVICE_TYPE" in fi if [ -n "$CEC_DEV" ]; then - # libcec solo prueba /dev/cec0 — no enumera /dev/cec1 - # aunque sea el puerto realmente conectado a la TV - # (confirmado en hardware: con solo /dev/cec1 montado con - # su propio nombre, cec.init() tira "No default adapter - # found"). Remapeamos el puerto que esté vivo al path fijo - # /dev/cec0 dentro del contenedor, sin importar a qué - # micro-HDMI físico corresponda en el host. + # libcec only ever probes /dev/cec0 — it doesn't enumerate + # /dev/cec1, even when that's the port actually connected + # to the TV (confirmed on hardware: with only /dev/cec1 + # mounted under its real name, cec.init() raises "No + # default adapter found"). Remap whichever port is live to + # the fixed path /dev/cec0 inside the container, regardless + # of which physical micro-HDMI port it corresponds to on + # the host. sed -i "s|^\([[:space:]]*\)- \"/dev/vchiq:/dev/vchiq\"\$|\1- \"$CEC_DEV:/dev/cec0\"|" \ /home/${USER}/anthias/docker-compose.yml else - # cec-ctl no está en el host, o ningún puerto reportó - # dirección física (TV apagada durante el upgrade, por - # ejemplo): mantenemos el comportamiento anterior — - # montar los dos con su nombre real. Si el puerto vivo - # resulta ser /dev/cec1, va a seguir sin funcionar hasta - # el próximo upgrade que sí pueda detectarlo; es una - # limitación conocida de este fallback degradado. - 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 + # cec-ctl isn't on the host, or no port reported a valid + # physical address (e.g. the TV was off during the + # upgrade). Mount whichever real /dev/cecN nodes actually + # exist on the host, under their real names — devices: + # fails container start if a listed host path doesn't + # exist, so we can't assume both are always present. + # If the live port turns out to be /dev/cec1, this will + # keep not working until a future upgrade run can detect + # it (cec-ctl becomes available, or the TV is on); that's + # a known limitation of this degraded fallback. + MOUNT_REPL="" + if [ -e /dev/cec0 ]; then + MOUNT_REPL='\1- "/dev/cec0:/dev/cec0"' + fi + if [ -e /dev/cec1 ]; then + if [ -n "$MOUNT_REPL" ]; then + MOUNT_REPL="${MOUNT_REPL}\\n\\1- \"/dev/cec1:/dev/cec1\"" + else + MOUNT_REPL='\1- "/dev/cec1:/dev/cec1"' + fi + fi + + if [ -n "$MOUNT_REPL" ]; then + sed -i "s|^\([[:space:]]*\)- \"/dev/vchiq:/dev/vchiq\"\$|${MOUNT_REPL}|" \ + /home/${USER}/anthias/docker-compose.yml + else + # Neither node exists — same situation as a board + # with no CEC adapter at all; drop the mount entirely. + sed -i '/devices:/ {N; /\n.*\/dev\/vchiq:\/dev\/vchiq/d}' \ + /home/${USER}/anthias/docker-compose.yml + fi fi ;; 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 From bf3d6b8eb9271986fa7f8c8577556976c6f15a3d Mon Sep 17 00:00:00 2001 From: Federico Pratto Date: Thu, 18 Jun 2026 14:39:37 -0300 Subject: [PATCH 3/3] style(cec): use [[ ]] for conditionals per SonarCloud suggestion --- bin/upgrade_containers.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/upgrade_containers.sh b/bin/upgrade_containers.sh index b46a6352e..efd3d2c9b 100755 --- a/bin/upgrade_containers.sh +++ b/bin/upgrade_containers.sh @@ -167,17 +167,17 @@ case "$DEVICE_TYPE" in CEC_DEV="" if command -v cec-ctl >/dev/null 2>&1; then for DEV in /dev/cec0 /dev/cec1; do - [ -e "$DEV" ] || continue + [[ -e "$DEV" ]] || continue PHYS_ADDR=$(cec-ctl -d "$DEV" 2>/dev/null \ | grep "Physical Address" | head -1 | awk -F: '{print $2}' | xargs) - if [ -n "$PHYS_ADDR" ] && [ "$PHYS_ADDR" != "f.f.f.f" ]; then + if [[ -n "$PHYS_ADDR" ]] && [[ "$PHYS_ADDR" != "f.f.f.f" ]]; then CEC_DEV="$DEV" break fi done fi - if [ -n "$CEC_DEV" ]; then + if [[ -n "$CEC_DEV" ]]; then # libcec only ever probes /dev/cec0 — it doesn't enumerate # /dev/cec1, even when that's the port actually connected # to the TV (confirmed on hardware: with only /dev/cec1 @@ -200,18 +200,18 @@ case "$DEVICE_TYPE" in # it (cec-ctl becomes available, or the TV is on); that's # a known limitation of this degraded fallback. MOUNT_REPL="" - if [ -e /dev/cec0 ]; then + if [[ -e /dev/cec0 ]]; then MOUNT_REPL='\1- "/dev/cec0:/dev/cec0"' fi - if [ -e /dev/cec1 ]; then - if [ -n "$MOUNT_REPL" ]; then + if [[ -e /dev/cec1 ]]; then + if [[ -n "$MOUNT_REPL" ]]; then MOUNT_REPL="${MOUNT_REPL}\\n\\1- \"/dev/cec1:/dev/cec1\"" else MOUNT_REPL='\1- "/dev/cec1:/dev/cec1"' fi fi - if [ -n "$MOUNT_REPL" ]; then + if [[ -n "$MOUNT_REPL" ]]; then sed -i "s|^\([[:space:]]*\)- \"/dev/vchiq:/dev/vchiq\"\$|${MOUNT_REPL}|" \ /home/${USER}/anthias/docker-compose.yml else