mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 00:57:38 -04:00
* feat(settings): experimental HDMI-CEC display on/off (#2575) - Add `set_display_power(on)` + `cec_available()` to `lib/diagnostics`, mirroring the existing CEC-subprocess-with-timeout pattern. - Add `/settings/display/<on|off>/` Django POST handler that surfaces success / failure to the operator as a toast (issue #2575 asks for a visible feedback loop). - Add `POST /api/v2/display/<state>` mirroring reboot / shutdown; 200 on success, 502 on CEC failure, 400 on invalid state. - Render-time gate via `cec_available()`: section is hidden when neither `/dev/cec0` nor `/dev/vchiq` exists (covers most x86 PCs). - Mark the new section "Experimental" with an amber warning chip using the existing `--color-warning-soft` / `--color-warning-strong` tokens. - Tests: unit (diagnostics, Django view, v2 API) + Playwright integration (hidden state, visible state, error toast, success toast). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: apply ruff format to new CEC files CI's `ruff format --check` flagged the five files added in the previous commit. No functional changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cec): address Copilot review on PR #2886 - views.py / mixins.py: short-circuit display-power POST/API on cec_available()=False so a stale form / direct curl can't burn a 10 s libcec subprocess on non-CEC hardware. API now returns 503. - diagnostics.set_display_power: when the subprocess emits neither the OK nor ERROR sentinel, fall back to the last stderr line (or the returncode) so the operator gets an actionable failure detail instead of a generic "unexpected CEC response." Message capped at ~240 chars to keep toasts / JSON responses sane. - tests/test_app.py: write screenshots to relative `test-artifacts/cec` instead of an absolute `/usr/src/app/...` path so local runs and alternative CI layouts pick them up via the same `--output test-artifacts` convention pytest-playwright already uses. - Tests updated to cover the new cec_available() guard (HTML + v2 API) and the new stderr / returncode fallbacks in set_display_power. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): declare display-power response schema for OpenAPI Copilot follow-up on PR #2886. - `DisplayPowerViewSerializerMixin` was empty (matching the pattern the Reboot / Shutdown mixins use), but unlike those this endpoint *does* return a JSON body — `{message: ...}` on every status code. Add a read-only `message` field so drf-spectacular has a real shape to render. - Declare `responses={200, 400, 502, 503: ...}` on the view's `@extend_schema` so the generated OpenAPI document mirrors what clients will actually receive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cec): cap ERROR-branch detail + a11y space in section heading Two Copilot follow-ups on PR #2886. - set_display_power: the ERROR: sentinel branch previously returned the raw remainder of stdout. A chatty libcec build could blow up the toast / JSON response with a multi-line / multi-kilobyte payload. Extract the cap + last-line trim into `_trim_cec_detail` and apply it to both the ERROR branch and the unexpected-output fallback. - settings.html: the "Display power" heading concatenated the title and the "Experimental" badge with no whitespace, so screen readers and copy/paste yielded "Display powerExperimental". Add a literal space + an explicit aria-label on the badge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
import os
|
|
import subprocess
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from anthias_server.lib import diagnostics
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'env_value,expected',
|
|
[
|
|
('master', 'master'),
|
|
('feature/foo', 'feature/foo'),
|
|
(None, None),
|
|
],
|
|
)
|
|
def test_get_git_branch(
|
|
monkeypatch: Any, env_value: str | None, expected: str | None
|
|
) -> None:
|
|
if env_value is None:
|
|
monkeypatch.delenv('GIT_BRANCH', raising=False)
|
|
else:
|
|
monkeypatch.setenv('GIT_BRANCH', env_value)
|
|
assert diagnostics.get_git_branch() == expected
|
|
|
|
|
|
def test_get_git_short_hash(monkeypatch: Any) -> None:
|
|
monkeypatch.setenv('GIT_SHORT_HASH', 'abc1234')
|
|
assert diagnostics.get_git_short_hash() == 'abc1234'
|
|
|
|
monkeypatch.delenv('GIT_SHORT_HASH', raising=False)
|
|
assert diagnostics.get_git_short_hash() is None
|
|
|
|
|
|
def test_get_git_hash(monkeypatch: Any) -> None:
|
|
monkeypatch.setenv('GIT_HASH', 'abc1234deadbeef')
|
|
assert diagnostics.get_git_hash() == 'abc1234deadbeef'
|
|
|
|
monkeypatch.delenv('GIT_HASH', raising=False)
|
|
assert diagnostics.get_git_hash() is None
|
|
|
|
|
|
def test_get_uptime_reads_proc_uptime() -> None:
|
|
fake_uptime = '12345.67 234567.89\n'
|
|
m_open = mock.mock_open(read_data=fake_uptime)
|
|
with mock.patch('builtins.open', m_open):
|
|
assert diagnostics.get_uptime() == pytest.approx(12345.67)
|
|
m_open.assert_called_once_with('/proc/uptime', 'r')
|
|
|
|
|
|
def test_get_load_avg() -> None:
|
|
with mock.patch.object(
|
|
os, 'getloadavg', return_value=(0.123, 0.456, 1.789)
|
|
):
|
|
result = diagnostics.get_load_avg()
|
|
assert result == {'1 min': 0.12, '5 min': 0.46, '15 min': 1.79}
|
|
|
|
|
|
def test_get_utc_isodate_format() -> None:
|
|
iso = diagnostics.get_utc_isodate()
|
|
# Sanity: looks like an ISO-format timestamp.
|
|
assert 'T' in iso
|
|
assert len(iso) >= len('2025-01-01T00:00:00')
|
|
|
|
|
|
def test_get_debian_version_reads_file(tmp_path: Any) -> None:
|
|
debian_file = tmp_path / 'debian_version'
|
|
debian_file.write_text('13.0\n')
|
|
with mock.patch.object(os.path, 'isfile', return_value=True):
|
|
m_open = mock.mock_open(read_data='13.0\n')
|
|
with mock.patch('builtins.open', m_open):
|
|
assert diagnostics.get_debian_version() == '13.0'
|
|
|
|
|
|
def test_get_debian_version_missing_file() -> None:
|
|
with mock.patch.object(os.path, 'isfile', return_value=False):
|
|
assert (
|
|
diagnostics.get_debian_version() == 'Unable to get Debian version.'
|
|
)
|
|
|
|
|
|
def test_get_raspberry_code_returns_hardware() -> None:
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.device_helper.parse_cpu_info',
|
|
return_value={'hardware': 'BCM2711', 'model': 'Pi 4'},
|
|
):
|
|
assert diagnostics.get_raspberry_code() == 'BCM2711'
|
|
|
|
|
|
def test_get_raspberry_code_unknown() -> None:
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.device_helper.parse_cpu_info',
|
|
return_value={},
|
|
):
|
|
assert diagnostics.get_raspberry_code() == 'Unknown'
|
|
|
|
|
|
def test_get_raspberry_model_returns_model() -> None:
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.device_helper.parse_cpu_info',
|
|
return_value={'model': 'Raspberry Pi 4 Model B'},
|
|
):
|
|
assert diagnostics.get_raspberry_model() == 'Raspberry Pi 4 Model B'
|
|
|
|
|
|
def test_get_raspberry_model_unknown() -> None:
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.device_helper.parse_cpu_info',
|
|
return_value={},
|
|
):
|
|
assert diagnostics.get_raspberry_model() == 'Unknown'
|
|
|
|
|
|
def test_get_display_power_true() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'True'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
assert diagnostics.get_display_power() is True
|
|
|
|
|
|
def test_get_display_power_false() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'False'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
assert diagnostics.get_display_power() is False
|
|
|
|
|
|
def test_get_display_power_cec_error() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'CEC error'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
assert diagnostics.get_display_power() == 'CEC error'
|
|
|
|
|
|
def test_get_display_power_unknown() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'Unknown'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
assert diagnostics.get_display_power() == 'Unknown'
|
|
|
|
|
|
def test_get_display_power_empty_output_returns_cec_error() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b''
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
assert diagnostics.get_display_power() == 'CEC error'
|
|
|
|
|
|
def test_set_display_power_on_success() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'OK'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is True
|
|
assert 'on' in msg
|
|
|
|
|
|
def test_set_display_power_off_success() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'OK'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=False)
|
|
assert ok is True
|
|
assert 'off' in msg
|
|
|
|
|
|
def test_set_display_power_cec_error_passes_through_reason() -> None:
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'ERROR: no adapter'
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert 'no adapter' in msg
|
|
|
|
|
|
def test_set_display_power_timeout_returns_failure_message() -> None:
|
|
with mock.patch.object(
|
|
subprocess,
|
|
'run',
|
|
side_effect=subprocess.TimeoutExpired(cmd='python', timeout=10),
|
|
):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert 'timed out' in msg.lower()
|
|
|
|
|
|
def test_set_display_power_unexpected_stdout_falls_through_to_stdout() -> None:
|
|
"""No 'OK' / 'ERROR:' sentinel — the helper still has to return
|
|
something actionable. With non-empty stdout and a clean exit, that
|
|
becomes the raw line itself (capped)."""
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'something weird'
|
|
completed.stderr = b''
|
|
completed.returncode = 0
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert 'something weird' in msg
|
|
|
|
|
|
def test_set_display_power_subprocess_crash_surfaces_stderr() -> None:
|
|
"""When stdout is empty and stderr has content (interpreter crash,
|
|
libcec writing to stderr), the last line of stderr is what reaches
|
|
the toast — gives the operator a real reason instead of a generic
|
|
'unexpected response.'"""
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b''
|
|
completed.stderr = (
|
|
b'Traceback (most recent call last):\n'
|
|
b' File "<string>", line 4, in <module>\n'
|
|
b'RuntimeError: cec init failed: no adapter\n'
|
|
)
|
|
completed.returncode = 1
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert 'RuntimeError: cec init failed: no adapter' in msg
|
|
|
|
|
|
def test_set_display_power_subprocess_crash_with_empty_streams_reports_status() -> (
|
|
None
|
|
):
|
|
"""Last-resort fallback: subprocess exits non-zero with no stderr
|
|
and no stdout. Still has to report something — surface the returncode."""
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b''
|
|
completed.stderr = b''
|
|
completed.returncode = 137
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert '137' in msg
|
|
|
|
|
|
def test_set_display_power_caps_long_error_message() -> None:
|
|
"""libcec can spew kilobytes of diagnostic output; the toast / API
|
|
body must not carry an unbounded blob."""
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b''
|
|
completed.stderr = ('X' * 4000).encode()
|
|
completed.returncode = 1
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
# Cap is 240; message has prefix "Display turn-on failed: " so total
|
|
# is under ~280 chars and ends with the ellipsis sentinel.
|
|
assert len(msg) < 300
|
|
assert msg.endswith('...')
|
|
|
|
|
|
def test_set_display_power_caps_long_error_sentinel_reason() -> None:
|
|
"""The ERROR: sentinel branch must apply the same length cap +
|
|
last-line trim as the unexpected-stdout fallback; a hostile or
|
|
chatty libcec build could otherwise smuggle a multi-line / huge
|
|
string into the toast via the contract path."""
|
|
long_reason = 'X' * 4000
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = f'ERROR: {long_reason}'.encode()
|
|
completed.stderr = b''
|
|
completed.returncode = 0
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert len(msg) < 300
|
|
assert msg.endswith('...')
|
|
|
|
|
|
def test_set_display_power_error_sentinel_strips_multiline() -> None:
|
|
"""Multi-line reason on the ERROR: branch — we keep only the last
|
|
non-empty line so the toast stays one row tall."""
|
|
completed = mock.MagicMock(spec=subprocess.CompletedProcess)
|
|
completed.stdout = b'ERROR: first line\nmiddle line\nactual failure reason'
|
|
completed.stderr = b''
|
|
completed.returncode = 0
|
|
with mock.patch.object(subprocess, 'run', return_value=completed):
|
|
ok, msg = diagnostics.set_display_power(on=True)
|
|
assert ok is False
|
|
assert 'actual failure reason' in msg
|
|
assert 'first line' not in msg
|
|
assert 'middle line' not in msg
|
|
|
|
|
|
def test_cec_available_true_when_cec0_present() -> None:
|
|
with mock.patch.object(
|
|
os.path, 'exists', side_effect=lambda p: p == '/dev/cec0'
|
|
):
|
|
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'
|
|
):
|
|
assert diagnostics.cec_available() is True
|
|
|
|
|
|
def test_cec_available_false_when_neither_present() -> None:
|
|
with mock.patch.object(os.path, 'exists', return_value=False):
|
|
assert diagnostics.cec_available() is False
|
|
|
|
|
|
def test_get_display_power_subprocess_timeout() -> None:
|
|
with mock.patch.object(
|
|
subprocess,
|
|
'run',
|
|
side_effect=subprocess.TimeoutExpired(cmd='cec', timeout=10),
|
|
):
|
|
assert diagnostics.get_display_power() == 'CEC error'
|
|
|
|
|
|
def test_try_connectivity_all_succeed() -> None:
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.utils.url_fails', return_value=False
|
|
):
|
|
results = diagnostics.try_connectivity()
|
|
assert len(results) == 4
|
|
for line in results:
|
|
assert line.endswith(': OK')
|
|
|
|
|
|
def test_try_connectivity_all_fail() -> None:
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.utils.url_fails', return_value=True
|
|
):
|
|
results = diagnostics.try_connectivity()
|
|
assert len(results) == 4
|
|
for line in results:
|
|
assert line.endswith(': Error')
|
|
|
|
|
|
def test_try_connectivity_mixed() -> None:
|
|
# Alternate True/False/True/False across the four URLs.
|
|
side_effect = [True, False, True, False]
|
|
with mock.patch(
|
|
'anthias_server.lib.diagnostics.utils.url_fails',
|
|
side_effect=side_effect,
|
|
):
|
|
results = diagnostics.try_connectivity()
|
|
assert results[0].endswith(': Error')
|
|
assert results[1].endswith(': OK')
|
|
assert results[2].endswith(': Error')
|
|
assert results[3].endswith(': OK')
|