fieldmesh

modules/notify/ — alerts to people, escalating until someone answers (layer 1)#

Every module so far ends at a decision in the log (§7.7): a trough is empty, bed 3 needs water, it will frost in the tunnel tonight. A decision nobody reads protects nothing. notify.py carries decisions to people, in each person's language, on the phone they have:

§11 asks that alerts escalate and need acknowledgement. A route says which rules go to whom, in tiers. Escalation works like this:

  1. The first tier is told at once.
  2. If nobody in it says "on it" within the route's time, the next tier is told.
  3. If nobody in a tier could be reached at all, the next tier is told without waiting.
  4. Once someone acknowledges, escalation stops, and everyone else who was told hears who took it.
  5. When the module that raised the alert clears it, everyone who was told hears that too.

An alert ends when a newer answer to the same question arrives (tonight's frost warning replaces last night's) or when its answer goes stale (valid_until; see modules/rules).

The notifier never imports the module whose decision it carries. Each decision names its own sentence and what it is about, and each module's language pack is data.

The roster#

Who gets what is the farm's private configuration, not the log:

{"farm": "Harrow Bend", "timezone": "America/Chicago",
 "channels": {"ntfy": {"server": "https://ntfy.example.org", "ack_topic": "hb-acks"},
              "sms": {"modem": "any"}},
 "people": {
   "maria": {"id": "0192…", "name": "María", "language": "es",
             "contacts": [{"via": "sms", "address": "+16155550101"}]},
   "sam":   {"id": "0192…", "name": "Sam", "language": "en", "units": "us", "quiet": ["21:00", "06:00"],
             "contacts": [{"via": "ntfy", "address": "hb-sam"},
                          {"via": "sms", "address": "+16155550102"}]}},
 "routes": [
   {"rules": ["water-*"], "tiers": [["maria"], ["sam"]], "escalate_after_min": 30, "urgent": true},
   {"rules": ["frost-watch", "covered-frost-watch"], "tiers": [["sam", "maria"]], "notify_clear": false},
   {"rules": ["soil-water-now"], "tiers": [["maria"]]}]}

The log names people and never their numbers. Each message sent or tried is written back as a notification event, and each acknowledgement as an acknowledgement event, the same kind the water monitor writes. A notification event records:

Phone numbers and topics stay in the roster. An export, or a view of the log shared with a researcher, carries who was told, never how to reach them.

Its memory stays a week long. An alert that ended more than seven days ago is forgotten. Of the events it wrote itself, it remembers only those its cursor has not read back yet. A year of daily alerts, tested, leaves about a week of them in memory, and the log keeps everything.

It restarts where the log says it was. A restarted notifier neither sends everything again nor forgets an alert half-escalated, and escalation keeps its original clock. It sends first and writes second, so a crash in between can repeat a message, never lose one.

Which replies count. A reply acknowledges alerts in three cases:

A reply that says no is never an acknowledgement, whatever else it contains. "No puedo 417", "si no puedo" and "can't 417" are all ignored, and the alert keeps climbing. The yes and no words are lists in each language pack (notify.ack_words, notify.no_words), so a new language brings its own.

On Harrow Bend#

test_notify.py runs the water monitor's §15 season on the virtual farm alongside the notifier, looking every ten minutes. The radio is out for twelve hours on day 2, and the south trough's float sticks on day 9. María reads Spanish on a basic phone; Sam has the push app. Nobody answers, so every alert climbs to Sam.

ten days, two troughs
alerts raised 5 (two for the outage, three as the float failed)
messages 12: 4 to María on raising, 4 escalated to Sam, 2 clears to each
SMS segments 22. Spanish needs "á", which is outside the GSM alphabet, so each SMS holds 70 characters, not 160. The longest, the outage's two alerts together, is 6
first message after a decision at the next look, within 10 minutes
escalation exactly 30 minutes after the first message

Alerts due together go as one message. When the radio both troughs hang off went quiet, María got one SMS listing both, with a number for each, and Sam got one push with one button that takes both. Sent one alert at a time, the same season was 16 messages and 23 segments. The log still holds one notification per alert, tied by a batch id, with the message's cost counted once.

What was checked, and what was not#

FIELDMESH_NTFY_SERVER=http://127.0.0.1:18432 python3 -m unittest test_notify.TestAgainstARealNtfyServer

Running it#

python3 sdk/modhost.py grant --log farm.jsonl --manifest modules/notify/module.json \
    --token-out /etc/fieldmesh/modules/notify.token
python3 -I modules/notify/notify.py --socket /run/fieldmesh/modules.sock --config roster.json

The grant reads decisions and acknowledgements from every module, and the grant record says so. Under systemd it is fieldmesh-module@notify with its drop-in (deploy/systemd/). No dependencies beyond Python's standard library, plus mmcli for SMS.

Choosing a push server (Q29):

Code AGPL-3.0-or-later (LICENSE); strings in packs/ under the notify. prefix, with the Spanish an unreviewed draft.

Next: SMS checked on a real modem.