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..9619de416 100755 --- a/bin/upgrade_containers.sh +++ b/bin/upgrade_containers.sh @@ -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 diff --git a/conftest.py b/conftest.py index a648d38d5..d76e41ebb 100644 --- a/conftest.py +++ b/conftest.py @@ -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() diff --git a/docker-compose.balena.dev.yml.tmpl b/docker-compose.balena.dev.yml.tmpl index d5c7af99f..4a7fffd49 100644 --- a/docker-compose.balena.dev.yml.tmpl +++ b/docker-compose.balena.dev.yml.tmpl @@ -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 @@ -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 diff --git a/docker-compose.balena.yml.tmpl b/docker-compose.balena.yml.tmpl index d0fd5f078..6742b5cfd 100644 --- a/docker-compose.balena.yml.tmpl +++ b/docker-compose.balena.yml.tmpl @@ -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 @@ -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 diff --git a/docker-compose.yml.tmpl b/docker-compose.yml.tmpl index 676ce7b63..b1b25a793 100644 --- a/docker-compose.yml.tmpl +++ b/docker-compose.yml.tmpl @@ -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 @@ -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 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/serializers/v2.py b/src/anthias_server/api/serializers/v2.py index b8525a4b7..23ada4e1e 100644 --- a/src/anthias_server/api/serializers/v2.py +++ b/src/anthias_server/api/serializers/v2.py @@ -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]: @@ -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]): @@ -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() diff --git a/src/anthias_server/api/tests/test_v2_endpoints.py b/src/anthias_server/api/tests/test_v2_endpoints.py index c415a3515..d78238235 100644 --- a/src/anthias_server/api/tests/test_v2_endpoints.py +++ b/src/anthias_server/api/tests/test_v2_endpoints.py @@ -12,6 +12,8 @@ from rest_framework import status from rest_framework.test import APIClient +from anthias_server.settings import settings + # Centralised fixture password for the basic-auth flow tests so Sonar's # S2068 fires once per file, not once per test. Avoids dictionary # words / breached-password tokens to keep S6437 quiet too. Never @@ -50,6 +52,10 @@ def test_get_device_settings( 'prefer_dark_mode': True, 'verify_ssl': True, 'screen_rotation': 90, + 'display_power_schedule_enabled': False, + 'display_power_on_time': '08:00', + 'display_power_off_time': '18:00', + 'display_power_days': '0,1,2,3,4,5,6', }[key] response = api_client.get(device_settings_url) @@ -367,6 +373,10 @@ def test_disable_basic_auth( 'prefer_dark_mode': False, 'verify_ssl': True, 'screen_rotation': 0, + 'display_power_schedule_enabled': False, + 'display_power_on_time': '08:00', + 'display_power_off_time': '18:00', + 'display_power_days': '0,1,2,3,4,5,6', }[key] settings_mock.__setitem__ = mock.MagicMock() @@ -688,9 +698,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'}) ) @@ -751,3 +761,119 @@ def test_patch_device_settings_password_mismatch_is_not_logged_as_error( # Sentry event. assert all(r.levelno == logging.WARNING for r in save_records) assert all(r.exc_info is None for r in save_records) + + +# --------------------------------------------------------------------------- +# Scheduled display power over the v2 settings API +# +# The HTML form path and this endpoint are documented mirrors of each +# other, so the schedule has to be settable from both. +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_device_settings_get_exposes_the_display_schedule( + api_client: APIClient, +) -> None: + response = api_client.get(reverse('api:device_settings_v2')) + assert response.status_code == status.HTTP_200_OK + for field in ( + 'display_power_schedule_enabled', + 'display_power_on_time', + 'display_power_off_time', + 'display_power_days', + ): + assert field in response.data + + +@pytest.mark.django_db +def test_device_settings_patch_sets_the_display_schedule( + api_client: APIClient, _isolated_settings_conf: Any +) -> None: + with mock.patch( + 'anthias_server.settings.ViewerPublisher.send_to_viewer', + return_value=None, + ): + response = api_client.patch( + reverse('api:device_settings_v2'), + { + 'display_power_schedule_enabled': True, + 'display_power_on_time': '07:30', + 'display_power_off_time': '19:45', + 'display_power_days': '4,0,2,0', + }, + format='json', + ) + assert response.status_code == status.HTTP_200_OK + settings.load() + assert settings['display_power_schedule_enabled'] is True + assert settings['display_power_on_time'] == '07:30' + assert settings['display_power_off_time'] == '19:45' + # Sorted and deduped by the serializer. + assert settings['display_power_days'] == '0,2,4' + + +@pytest.mark.django_db +def test_device_settings_patch_accepts_seconds_in_a_time( + api_client: APIClient, _isolated_settings_conf: Any +) -> None: + """An with a sub-minute step posts HH:MM:SS.""" + with mock.patch( + 'anthias_server.settings.ViewerPublisher.send_to_viewer', + return_value=None, + ): + response = api_client.patch( + reverse('api:device_settings_v2'), + {'display_power_on_time': '07:30:00'}, + format='json', + ) + assert response.status_code == status.HTTP_200_OK + settings.load() + assert settings['display_power_on_time'] == '07:30' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + 'payload', + [ + {'display_power_on_time': 'not-a-time'}, + {'display_power_off_time': '25:00'}, + # Seconds are dropped from the stored value, but a malformed or + # out-of-range seconds token must still be a 400 rather than + # being read as a valid 07:30 (Copilot). + {'display_power_on_time': '07:30:xx'}, + {'display_power_off_time': '19:45:60'}, + {'display_power_days': '0,9'}, + {'display_power_days': 'mon,tue'}, + ], +) +def test_device_settings_patch_rejects_a_bad_schedule( + api_client: APIClient, + payload: dict[str, Any], + _isolated_settings_conf: Any, +) -> None: + """400 rather than silent coercion. The HTML form keeps the previous + value and toasts, but an API client that explicitly sent a field + deserves to be told it was wrong.""" + response = api_client.patch( + reverse('api:device_settings_v2'), payload, format='json' + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_device_settings_patch_blank_days_means_every_day( + api_client: APIClient, _isolated_settings_conf: Any +) -> None: + with mock.patch( + 'anthias_server.settings.ViewerPublisher.send_to_viewer', + return_value=None, + ): + response = api_client.patch( + reverse('api:device_settings_v2'), + {'display_power_days': ''}, + format='json', + ) + assert response.status_code == status.HTTP_200_OK + settings.load() + assert settings['display_power_days'] == '0,1,2,3,4,5,6' 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/api/views/v2.py b/src/anthias_server/api/views/v2.py index 1a0f3db3d..e0809b429 100644 --- a/src/anthias_server/api/views/v2.py +++ b/src/anthias_server/api/views/v2.py @@ -597,6 +597,12 @@ def get(self, request: Request) -> Response: if settings['auth_backend'] == 'auth_basic' else '' ), + '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': settings['display_power_days'], } ) @@ -674,6 +680,17 @@ def patch(self, request: Request) -> Response: settings['verify_ssl'] = data['verify_ssl'] if 'screen_rotation' in data: settings['screen_rotation'] = int(data['screen_rotation']) + # Scheduled display power. Already normalised by the + # serializer's validators ('HH:MM', sorted weekday list), so + # the beat can never read a value it cannot parse. + for field in ( + 'display_power_schedule_enabled', + 'display_power_on_time', + 'display_power_off_time', + 'display_power_days', + ): + if field in data: + settings[field] = data[field] settings.save() publisher = ViewerPublisher.get_instance() diff --git a/src/anthias_server/app/page_context.py b/src/anthias_server/app/page_context.py index 93f056fc7..1a7ea6e44 100644 --- a/src/anthias_server/app/page_context.py +++ b/src/anthias_server/app/page_context.py @@ -24,7 +24,7 @@ get_node_mac_address, is_balena_app, ) -from anthias_server.lib import diagnostics +from anthias_server.lib import diagnostics, display_power from anthias_server.lib.github import is_up_to_date from anthias_server.lib.timezone import format_utc_offset from anthias_server.settings import settings @@ -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,28 @@ 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() reads a single Redis key the viewer + # publishes at startup — not a device probe and not a round trip + # to the viewer — 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'], + # Parsed with the same helper the beat uses. An inline + # comprehension here had the opposite empty-input behaviour — + # it rendered *zero* days checked where parse_days reads the + # same value as *every* day, so the settings page would show a + # schedule running on no days while it actually ran daily. + 'display_power_days': sorted( + display_power.parse_days(settings['display_power_days']) + ), + 'weekday_options': _WEEKDAY_OPTIONS, } diff --git a/src/anthias_server/app/static/sass/_styles.scss b/src/anthias_server/app/static/sass/_styles.scss index af3443883..80c711c5b 100644 --- a/src/anthias_server/app/static/sass/_styles.scss +++ b/src/anthias_server/app/static/sass/_styles.scss @@ -1332,6 +1332,7 @@ label { text-align: left; } .weekday { --bg: var(--color-surface-tint); --fg: var(--color-text); + position: relative; border: 1px solid var(--color-border); background: var(--bg); color: var(--fg); @@ -1341,13 +1342,32 @@ label { text-align: left; } cursor: pointer; user-select: none; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); - input { display: none; } + + // Visually hidden but still focusable and still announced. This was + // `display: none`, which drops the control out of the tab order and + // the accessibility tree entirely — the chips could only be operated + // with a pointer. Clipping instead keeps them keyboard- and + // screen-reader-reachable while looking identical. + input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; + } } .weekday:has(input:checked) { --bg: #{$anthias-yellow-3}; --fg: var(--color-accent-text); border-color: $anthias-yellow-2; } + // The input itself is invisible, so the ring has to be drawn on the + // chip. Mirrors .app-check-input's focus treatment. + .weekday:has(input:focus-visible) { + outline: none; + box-shadow: 0 0 0 var(--ring-width) var(--color-focus-ring); + } } // ----------------------------------------------------------------------------- diff --git a/src/anthias_server/app/templates/settings.html b/src/anthias_server/app/templates/settings.html index ed4846c20..ac0ccaa31 100644 --- a/src/anthias_server/app/templates/settings.html +++ b/src/anthias_server/app/templates/settings.html @@ -151,6 +151,86 @@

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

+

+ Switch the screen off outside opening hours. A TV that supports + HDMI-CEC powers down; any other display goes black. +

+
+
+ +
+
+ {% include "_settings_toggle.html" with name="display_power_schedule_enabled" label="Enable schedule" hint="Outside the hours below, the screen switches off." checked=display_power_schedule_enabled %} +
+ + {% comment %} + x-cloak matches the asset modal's collapsible sections: without + it the fields flash open on first paint before Alpine + initialises and applies x-show. + {% endcomment %} +
+ {% comment %} + type="time" rather than the .js-flatpickr-time treatment used + in the asset modal — flatpickr is initialised in home.ts, + which this page does not load. + {% endcomment %} +
+
+ + +
+
+ + +
+
+ +

+ Active days. An overnight schedule belongs to the day it starts — + 18:00 to 06:00 on Friday runs into Saturday morning. +

+ {% comment %} + Same .weekday-picker pill chips the asset modal and bulk-edit + modal use for choosing days, so the two places in the product + that pick weekdays look and behave identically. + {% endcomment %} +
+ {% for value, label in weekday_options %} + + {% endfor %} +
+
+
+
+