Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 3 additions & 1 deletion docs/command-line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ Diagnostic and one-off actions
legacy or key-specific relay output sequentially for one second and test
every configured WAVE file. Disconnect the external bell circuit and use a
safe audio level first. ``--check`` remains non-destructive when both flags
are supplied.
are supplied. ``--test`` can run alongside ``school-bell.service`` when
they do not claim the same GPIO pins. If a configured pin is already in
use, stop the service before retrying the test.

``--debug``
Enable verbose local logging. It can reveal hostnames and operational
Expand Down
50 changes: 49 additions & 1 deletion src/school_bell/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,43 @@ def _safe_startup_message(error, config=None):
return (message or 'No error details available')[:500]


class _GPIOBusyDuringTest(RuntimeError):
"""Signal that an operational test cannot claim the configured GPIO."""


def _is_gpio_busy(error):
"""Return whether an exception chain reports an already claimed GPIO."""
seen = set()
while error is not None and id(error) not in seen:
seen.add(id(error))
if 'gpio busy' in str(error).lower():
return True
error = error.__cause__ or error.__context__
return False


def _report_gpio_busy(logger):
"""Explain the common service/test GPIO conflict without a traceback."""
message = (
'Cannot run --test because a configured GPIO pin is already in use. '
'If school-bell.service is running, stop it with '
'`sudo systemctl stop school-bell` and try again.'
)
logger.error(
message,
extra={
'event': 'startup_failed',
'status': 'failure',
'fields': {
'exception_type': 'GPIOBusy',
'startup_phase': 'service_initialization',
'error_message': message,
},
},
)
return message


def _configure_startup_monitoring(logger, config):
"""Best-effort remote logging setup from a parsed configuration."""
if not isinstance(config, dict):
Expand Down Expand Up @@ -235,6 +272,14 @@ def _initialize_service(args, logger, prog, info):
phase = 'service_initialization'
return SchoolBell(**runtime_config)
except Exception as error:
if (
phase == 'service_initialization'
and args.test
and not args.check
and _is_gpio_busy(error)
):
message = _report_gpio_busy(logger)
raise _GPIOBusyDuringTest(message) from None
_report_startup_failure(logger, error, phase, config)
raise

Expand Down Expand Up @@ -365,7 +410,10 @@ def main():
logger = init_logger(prog, args.debug)
if args.check:
return _check_configuration(args, logger, prog, info)
obj = _initialize_service(args, logger, prog, info)
try:
obj = _initialize_service(args, logger, prog, info)
except _GPIOBusyDuringTest:
return 1

# play a test file or run the schedule
try:
Expand Down
41 changes: 41 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,47 @@ def __init__(self, **config):
}


def test_test_mode_reports_busy_gpio_without_traceback(monkeypatch, caplog):
class BusySchoolBell:
def __init__(self, **config):
raise RuntimeError("'GPIO busy'")

monkeypatch.setattr(main_module, 'SchoolBell', BusySchoolBell)
monkeypatch.setattr(
main_module, '_configure_startup_monitoring', lambda *_: None
)
monkeypatch.setattr(sys, 'argv', [
'school-bell', json.dumps({'schedule': {}, 'wav': {}}), '--test',
])

assert main_module.main() == 1

assert 'Cannot run --test' in caplog.text
assert 'sudo systemctl stop school-bell' in caplog.text
record = next(
record for record in caplog.records
if getattr(record, 'event', None) == 'startup_failed'
)
assert record.exc_info is None


def test_non_test_gpio_busy_error_is_reraised(monkeypatch):
class BusySchoolBell:
def __init__(self, **config):
raise RuntimeError('GPIO busy')

monkeypatch.setattr(main_module, 'SchoolBell', BusySchoolBell)
monkeypatch.setattr(
main_module, '_configure_startup_monitoring', lambda *_: None
)
monkeypatch.setattr(
sys, 'argv', ['school-bell', json.dumps({'schedule': {}, 'wav': {}})]
)

with pytest.raises(RuntimeError, match='GPIO busy'):
main_module.main()


def test_startup_event_redacts_sensitive_configuration(monkeypatch):
records, handler = _capture_startup_events(monkeypatch)
secret = 'do-not-log-this-token'
Expand Down
Loading