diff --git a/docs/command-line.rst b/docs/command-line.rst index 287b78b..9f3df4f 100644 --- a/docs/command-line.rst +++ b/docs/command-line.rst @@ -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 diff --git a/src/school_bell/main.py b/src/school_bell/main.py index 830e5af..e4407f0 100644 --- a/src/school_bell/main.py +++ b/src/school_bell/main.py @@ -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): @@ -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 @@ -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: diff --git a/tests/test_main.py b/tests/test_main.py index dbf9591..c56b8e4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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'