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
6 changes: 4 additions & 2 deletions ansible/files/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
"1": "ClassBell-SoundBible.com-1426436341.wav"
},
"root": "${HOME}/samples",
"buzz_gpio": [26, 20],
"buzz_active_high": false,
"relays": [
{"gpio": 26, "wav_keys": "0", "active_high": false},
{"gpio": 20, "wav_keys": ["0", "1"], "active_high": false}
],
"manual_bell": {
"gpio": 17,
"wav_key": "0",
Expand Down
4 changes: 4 additions & 0 deletions demo.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"1": "ClassBell-SoundBible.com-1426436341.wav"
},
"root": "${HOME}/samples",
"relays": [
{"gpio": 26, "wav_keys": "0", "active_high": false},
{"gpio": 20, "wav_keys": ["0", "1"], "active_high": false}
],
"manual_bell": {
"gpio": 17,
"wav_key": "0",
Expand Down
61 changes: 52 additions & 9 deletions docs/GPIO.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,44 @@ GPIO number merely because its physical header position has that number.
Relay outputs
-------------

Key-specific relays
~~~~~~~~~~~~~~~~~~~

Use ``relays`` when different outputs must respond to different WAVE keys. A
relay accepts one key as a string or multiple keys as a list:

.. code-block:: json

{
"relays": [
{
"gpio": 26,
"wav_keys": "bell",
"active_high": false
},
{
"gpio": 20,
"wav_keys": ["workshop", "bell-and-light"],
"active_high": false
}
]
}

Only relays matching the requested WAVE key are active during playback. A key
without a matching relay remains a valid audio-only signal. Scheduled,
webhook and manual-button signals all use the same selection rule. The manual
button remains one input and selects outputs through its configured
``wav_key``.

Every relay key must exist in ``wav``. GPIO pins must be unique and cannot
reuse ``manual_bell.gpio``. ``active_high`` defaults to ``true`` independently
for each relay.

Legacy relay outputs
~~~~~~~~~~~~~~~~~~~~

``buzz_gpio`` accepts one BCM GPIO number or a list. With multiple entries all
configured relays switch together while the bell signal is playing:
configured relays switch together for every WAVE key:

.. code-block:: json

Expand All @@ -56,6 +92,9 @@ configured relays switch together while the bell signal is playing:
* ``true`` (the default) drives an output high to ring;
* ``false`` drives an output low to ring and is required for active-low boards.

Do not combine ``buzz_gpio`` with ``relays``. Existing configurations can keep
using the legacy settings until key-specific switching is needed.

School Bell initializes every output to its configured inactive state and
returns it to that state after ringing. Confirm the polarity for the actual
relay board before connecting the bell circuit; the wrong value can energize a
Expand Down Expand Up @@ -85,7 +124,8 @@ The three channels of the `Waveshare RPi Relay Board`_ use these values:

The printed P25, P28 and P29 labels use wiringPi numbering. Put the BCM values
``26``, ``20`` and ``21`` in the School Bell configuration. This board has
active-low inputs, so configure ``"buzz_active_high": false``.
active-low inputs, so configure ``"buzz_active_high": false`` for legacy
outputs or ``"active_high": false`` on each key-specific relay.

Consult the board manufacturer's documentation as well. Revisions or other
relay boards can use different pins and polarity.
Expand Down Expand Up @@ -135,10 +175,12 @@ be ``down`` or ``floating`` for hardware designed that way. A floating input
requires a suitable external resistor and should not be left electrically
unconnected.

The input pin must not also occur in ``buzz_gpio``. ``bounce_time`` filters
short contact bounce after pressing the button. In ``once`` mode one press
finishes the selected sound; in ``hold`` mode releasing the button stops it,
with the sample duration as the maximum.
The input pin must not also occur in ``buzz_gpio`` or ``relays``.
``bounce_time`` filters short contact bounce after pressing the button. In
``once`` mode one press finishes the selected sound; in ``hold`` mode releasing
the button stops it, with the sample duration as the maximum. The button's
``wav_key`` selects the same key-specific relay outputs as a scheduled or
webhook signal.

Manual signals are deliberate overrides and are not suppressed by holiday or
disable calendars. Only one bell signal can run at a time. See
Expand All @@ -160,9 +202,10 @@ Then disconnect the real bell circuit and run the hardware test:

$ school-bell /home/pi/schema.json --test

``--test`` activates each configured ``buzz_gpio`` output sequentially for one
second and returns it to the inactive state before testing the next output. It
also tests the configured WAVE samples, so keep audio at a safe level.
``--test`` activates every configured legacy or key-specific relay output
sequentially for one second and returns it to the inactive state before testing
the next output. WAVE-key filters do not limit this hardware test. It also tests
the configured WAVE samples, so keep audio at a safe level.

If an output does not behave as expected, stop and check the BCM number, relay
polarity, board power, connections and service-user permissions. Continue with
Expand Down
6 changes: 4 additions & 2 deletions docs/MONITORING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ Stable event names include ``service_started``, ``service_stopped``,
``schedule_loaded``, ``schedule_entry_loaded``, ``bell_ring``,
``bell_skipped_holiday``, ``gpio_test``,
``gpio_activated``, ``gpio_deactivated``, ``remote_trigger``, and
``health_status``. GPIO events include the configured pins, polarity and
resulting logical state. Remote-trigger events include the remote host, WAV
``health_status``. GPIO and bell events include only the selected pins. Their
``gpio_active_high`` value is a boolean when every selected relay has the same
polarity, a pin-aligned boolean list for mixed polarities, or ``null`` when no
relay matches. Remote-trigger events include the remote host, WAV
key, duration and a consistent success or failure status. Future calendar
monitoring can add ``bell_skipped_calendar``, ``calendar_refresh`` and
``calendar_error`` without changing the common fields.
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Added
* Central Pydantic 2 validation for the complete JSON configuration.
* Precise nested configuration error paths and JSON Schema generation.
* Authenticated HTTP bell webhooks.
* WAVE-key-specific relay outputs with per-relay polarity.
* Optional SSH and webhook actions for manual GPIO bells.
* Structured Sphinx documentation with architecture, networking, deployment,
configuration, monitoring, ALSA and troubleshooting guides.
Expand Down
15 changes: 8 additions & 7 deletions docs/command-line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ Diagnostic and one-off actions

``--test``
Exercise real hardware during initialization: activate every configured
``buzz_gpio`` 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.
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.

``--debug``
Enable verbose local logging. It can reveal hostnames and operational
Expand Down Expand Up @@ -80,7 +81,7 @@ tested release or commit with ``ansible/update.yml`` instead.
GPIO configuration
------------------

Relay pins are configured only through ``buzz_gpio`` and
``buzz_active_high`` in JSON. There is no command-line GPIO override. This
keeps the validated configuration, runtime behavior and reported configuration
hash aligned. See :doc:`GPIO` for wiring and testing.
Relay pins are configured through legacy ``buzz_gpio``/``buzz_active_high`` or
the key-specific ``relays`` list in JSON. There is no command-line GPIO
override. This keeps the validated configuration, runtime behavior and
reported configuration hash aligned. See :doc:`GPIO` for wiring and testing.
31 changes: 27 additions & 4 deletions docs/configuration-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ are placeholders.
},
"root": "/home/pi/samples",
"device": "plughw:CARD=Device,DEV=0",
"buzz_gpio": [20, 26],
"buzz_active_high": true,
"relays": [
{"gpio": 26, "wav_keys": "lesson", "active_high": false},
{
"gpio": 20,
"wav_keys": ["lesson", "break"],
"active_high": false
}
],
"timeout": 10,
"holidays": "BE-NL",
"trigger": {
Expand Down Expand Up @@ -124,6 +130,21 @@ Top-level fields
active-low relay boards. Confirm the safe inactive state before connecting
a real bell circuit.

``relays`` (array of objects or null; default ``null``)
Key-specific GPIO outputs. Each entry selects one BCM pin, one or more WAVE
keys and its own polarity. Do not combine this field with ``buzz_gpio``.

``relays[].gpio`` (integer, required)
Unique BCM GPIO output. It must differ from ``manual_bell.gpio``.

``relays[].wav_keys`` (string or array of strings, required)
One WAVE key or a non-empty list of keys that activates this relay. Every
key must exist in ``wav``. Keys without a matching relay play audio only.

``relays[].active_high`` (boolean, default ``true``)
Electrical polarity for this relay. Set it to ``false`` for an active-low
relay input.

``timeout`` (integer or null, effective default 10 seconds)
General timeout used by OpenHolidays and legacy SSH remote playback.
Historical numeric strings are accepted. Use a positive value in new files.
Expand Down Expand Up @@ -165,10 +186,12 @@ Physical manual bell

``manual_bell.gpio`` (integer, required)
BCM GPIO input connected to the button. It must differ from every
``buzz_gpio`` output. See :doc:`GPIO` for a pull-up wiring example.
``buzz_gpio`` or ``relays`` output. See :doc:`GPIO` for a pull-up wiring
example.

``manual_bell.wav_key`` (string, required)
Entry from ``wav`` to play when the button is pressed.
Entry from ``wav`` to play when the button is pressed. It also selects any
matching key-specific relay outputs.

``manual_bell.mode`` (``once`` or ``hold``, default ``once``)
``once`` plays the complete signal per press. ``hold`` stops playback when
Expand Down
11 changes: 6 additions & 5 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@ difference between scheduled and manual signals.
Outputs and triggers
--------------------

``device`` selects an ALSA playback device. ``buzz_gpio`` accepts one BCM GPIO
number or a list. ``buzz_active_high`` defaults to ``true``; set it to
``false`` for active-low relay boards. See :doc:`GPIO` before selecting pins or
connecting a relay.
``device`` selects an ALSA playback device. The legacy ``buzz_gpio`` setting
activates one or more outputs for every WAVE key. Use ``relays`` when outputs
must respond only to selected WAVE keys or need different polarities. The two
forms cannot be combined. See :doc:`GPIO` before selecting pins or connecting
a relay.

``manual_bell`` configures one physical input button. Its ``gpio`` cannot also
be an output, and its ``wav_key`` must exist. ``mode`` is ``once`` or ``hold``;
Expand Down Expand Up @@ -112,7 +113,7 @@ integration at a time and run ``--check`` after every change. A typical order
is:

#. confirm local WAVE playback;
#. add GPIO relay outputs if the installation uses them;
#. add legacy or key-specific GPIO relay outputs if the installation uses them;
#. add holiday or calendar suppression;
#. add a physical manual button or authenticated webhook;
#. add monitoring and verify Graylog or the HTTP health endpoint.
Expand Down
4 changes: 2 additions & 2 deletions docs/schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,6 @@ Limits of a generated schema

Some checks depend on relationships or runtime resources and cannot be fully
expressed by editor hints alone. Examples include verifying that every schedule
key selects an existing ``wav`` entry, that a manual input does not reuse an
output pin, that a WAVE file exists, and that a remote calendar responds.
or relay key selects an existing ``wav`` entry, that a manual input does not
reuse an output pin, that a WAVE file exists, and that a remote calendar responds.
Pydantic and ``--check`` remain the final authority.
4 changes: 3 additions & 1 deletion docs/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ GPIO does not switch
--------------------

Check BCM pin numbering, relay polarity, power and service-user permissions.
Active-low relay boards require ``"buzz_active_high": false``. Never diagnose
Active-low relay boards require ``"buzz_active_high": false`` for legacy
outputs or ``"active_high": false`` on each key-specific relay. Also confirm
that the requested WAVE key occurs in the relay's ``wav_keys``. Never diagnose
relay wiring with a live high-voltage or bell circuit attached. The pin-number
explanation, Waveshare mapping and safe test sequence are in :doc:`GPIO`.

Expand Down
59 changes: 57 additions & 2 deletions src/school_bell/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,42 @@ def validate_enabled_webhook(self):
return self


class RelayConfig(ConfigModel):
gpio: int
wav_keys: str | list[str]
active_high: bool = True

@field_validator('gpio')
@classmethod
def gpio_is_not_boolean(cls, value):
if isinstance(value, bool):
raise ValueError('must be an integer')
return value

@field_validator('wav_keys', mode='before')
@classmethod
def normalize_wav_keys(cls, value):
values = [value] if isinstance(value, (str, int)) else value
if not isinstance(values, list):
return values
return [
str(item) if isinstance(item, int) and not isinstance(item, bool)
else item
for item in values
]

@field_validator('wav_keys')
@classmethod
def valid_wav_keys(cls, value):
if not value:
raise ValueError('must contain at least one WAVE key')
if any(not key for key in value):
raise ValueError('must contain non-empty WAVE keys')
if len(value) != len(set(value)):
raise ValueError('must not contain duplicate WAVE keys')
return value


class SchoolBellConfig(ConfigModel):
"""Complete user-controlled ``config.json`` contract."""

Expand All @@ -276,6 +312,7 @@ class SchoolBellConfig(ConfigModel):
device: str | None = None
buzz_gpio: int | list[int] | None = None
buzz_active_high: bool = True
relays: list[RelayConfig] | None = None
timeout: LegacyInt | None = None
holidays: str | None = None
trigger: dict[str, str] | None = None
Expand Down Expand Up @@ -365,21 +402,39 @@ def validate_references(self):
f'schedule.{day}.{configured_time} refers to unknown '
f'WAV key {wav_key!r}'
)
relay_pins = self._validate_relays(wav_keys)
if self.manual_bell is not None:
if self.manual_bell.wav_key not in wav_keys:
raise ValueError(
'manual_bell.wav_key refers to an unknown WAV key'
)
outputs = (
legacy_outputs = (
[self.buzz_gpio] if isinstance(self.buzz_gpio, int)
else (self.buzz_gpio or [])
)
outputs = legacy_outputs + relay_pins
if self.manual_bell.gpio in outputs:
raise ValueError(
'manual_bell.gpio must not also be a buzz_gpio output'
'manual_bell.gpio must not also be a relay output'
)
return self

def _validate_relays(self, wav_keys: set[str]) -> list[int]:
"""Validate relay exclusivity, pin uniqueness and WAVE references."""
if self.relays is not None and self.buzz_gpio is not None:
raise ValueError('buzz_gpio and relays cannot be combined')
relay_pins = [relay.gpio for relay in self.relays or []]
if len(relay_pins) != len(set(relay_pins)):
raise ValueError('relays must use unique GPIO pins')
for relay in self.relays or []:
unknown = set(relay.wav_keys) - wav_keys
if unknown:
raise ValueError(
f'relays GPIO {relay.gpio} refers to unknown WAV key(s): '
f'{sorted(unknown)!r}'
)
return relay_pins


def validate_config(config: object) -> SchoolBellConfig:
"""Validate an entire parsed JSON document in one operation."""
Expand Down
Loading
Loading