One YAML file, read from /config/wxalerts.yml (override with the
WXALERTS_CONFIG environment variable or -c). It is validated in full at
startup rather than lazily, because the first use of a broken rule is a tornado
warning at three in the morning.
There is no database and no admin UI. The configuration is a list of counties and a severity threshold that changes about twice a year; what you actually want to watch day to day, meaning what went out and what it cost in airtime, is MeshMonitor’s own message log.
The whole file
mqtt:
url: wss://mqtt.wxalerts.org/mqtt
username: wxalerts
password: wxalerts # public on purpose; the broker denies publish
meshmonitor:
url: http://meshmonitor:8080
token: ${MESHMONITOR_TOKEN}
source_id: default # optional; for a multi-source MeshMonitor
timezone: America/Chicago
counties:
- same: "012113"
label: Santa Rosa # what appears in the message
offices: [KMOB] # optional; marine zones have no county
delivery:
max_bytes: 200
warmup_seconds: 20
max_per_hour: 12
burst: 4
queue_max_age: 15m
send_all_clear: true
office_alerts: unzoned
dry_run: false
rules:
- name: life-threatening
phenomena: [TO, FF, SQ, HU]
significance: [W]
channel: WX
priority: 100
template: "{emoji}{event} {counties} til {ends} {instruction}"
${VAR} and ${VAR:-default} are expanded from the environment anywhere in
the file, so the token never has to live on disk. A ${VAR} with no default
and no environment variable set is a config error, not an empty string.
Durations (warmup_seconds, queue_max_age, min_interval) accept a bare
number of seconds or a suffixed string: 90, "90s", "15m", "2h".
Top level
| Key | Default | |
|---|---|---|
timezone |
UTC |
IANA zone. Every time in a message is rendered in it, so set it to the mesh’s local zone |
topic_root |
wxalerts/nws/v1 |
Only change this against a private broker |
log_level |
INFO (or $LOG_LEVEL) |
DEBUG logs every alert that matched no rule, with its codes |
http_port |
8092 |
Health, stats and metrics |
emoji |
see below | Per-phenomenon glyph overrides |
abbreviations |
see below | Event-name shortenings used when the full name will not fit |
mqtt
Defaults point at the public WxAlerts feed and are usually left alone. The credential is published deliberately, because the broker enforces that this account cannot publish, so the read-only feed needs no signup.
| Key | |
|---|---|
url |
ws, wss, mqtt, mqtts or tcp. The port and TLS follow the scheme; a websocket URL keeps its path, and a wrong path hangs the upgrade in a way that looks exactly like a firewall |
username / password |
both default to wxalerts |
client_id |
defaults to wxmesh-<hostname>. Two clients sharing an id kick each other off in a reconnect loop that looks like a flapping network, so set it explicitly if you run two bridges on one host |
meshmonitor
| Key | |
|---|---|
url |
Required, with scheme. Inside a container this is the compose service name, not localhost |
token |
Required. Must start with mm_, which is checked at load so a pasted-wrong value fails at startup rather than at the first warning |
source_id |
Optional. Omitted or default picks the source marked primary, else the first one. Otherwise matched against a source id or name |
timeout_seconds |
15 |
counties and offices
counties is the main subscription. Each entry is a six-digit SAME code and a
label:
counties:
- same: "012113"
label: Santa Rosa
Quote the code. YAML eats the leading zero on an unquoted 012113. The loader
re-pads it rather than rejecting it, because the mistake is near universal, but
quoting says what you meant. The label is what appears in the message; if you
omit it, the raw code does, which helps nobody. Run
init --lat <lat> --lon <lon> to look a code up.
offices subscribes to a WFO’s whole output. The reason to do it is marine and
offshore zones: those carry no SAME code at all, so they never appear on the
county tree and the forecast office is the only way to receive them. See
delivery.office_alerts for what gets kept.
delivery
| Key | Default | |
|---|---|---|
max_bytes |
200 |
Must be 40–237. Lowered automatically if the source caps lower; raising it above what the source allows does nothing |
warmup_seconds |
20 |
Quiet window after connecting, while the retained backlog is absorbed into state |
max_per_hour |
12 |
The airtime budget, refilled continuously rather than in an hourly step |
burst |
4 |
How many can go out back to back |
queue_max_age |
15m |
Anything still queued past this is discarded rather than delivered late |
send_all_clear |
true |
Announce hazards ending |
office_alerts |
unzoned |
unzoned keeps only alerts with no county behind them, meaning marine and offshore. all keeps everything the office issues, for every county in its area |
dry_run |
false |
Render and log, transmit nothing |
Rules
Rules are evaluated top to bottom and the first match wins, so they read like a firewall: specific rules above general ones. An alert matching no rule is never sent. The config is an allowlist, which means a new phenomenon code appearing upstream cannot start transmitting on its own.
| Key | Default | |
|---|---|---|
name |
rule<N> |
Appears in logs and in /stats per-rule counters |
phenomena |
any | VTEC codes: TO tornado, SV severe thunderstorm, FF flash flood, FL flood, WS winter storm, BZ blizzard, HW high wind, XH extreme heat, FW fire weather, HU hurricane, TR tropical storm, SQ snow squall |
significance |
any | W warning, A watch, Y advisory, S statement |
severity |
any | Extreme, Severe, Moderate, Minor |
channel |
0 |
A channel name resolved against MeshMonitor at startup, or a slot number 0–7 |
priority |
50 |
Who wins the queue when airtime is tight |
template |
see below | The message |
min_interval |
0 |
Floor between two sends from this rule |
all_clear |
true |
Announce when a hazard this rule sent ends |
destination |
none | A node id (!ad8c9eff) to send a direct message instead of broadcasting |
Every axis you name must match; an axis you omit matches anything. Codes are
upper-cased, so to and TO are the same.
Naming the channel
Prefer the name over the slot number. The slot depends on the order channels happened to be added to the device, and a wrong number transmits on the wrong channel instead of failing, which is the worst available outcome. A name is resolved at startup and a name MeshMonitor does not have stops the bridge with a list of the ones it does.
Names are matched ignoring case and a leading hash, because the same channel is
written three ways in practice: WX in a config, #wx on a MeshCore device,
and wx in someone’s head. WX and WEATHER, however, are two different
channels.
min_interval versus escalation
These do different jobs and both are needed. Escalation suppression asks “is
this the same thing I already said” and is always on; min_interval asks “am
I talking too much about this category” and is off unless you set it. A
tornado warning that upgrades from a watch is news and should not be blocked by
a category interval, so warning rules normally run with no interval at all,
and advisory rules run with a long one.
Templates
The default:
{emoji}{event} {counties} til {ends} {instruction}
A field the alert does not carry renders as empty and the surrounding whitespace collapses, so a template can name fields that only some alerts have.
| Field | |
|---|---|
{emoji} |
Phenomenon glyph plus a space, or nothing |
{event} |
Event name, upper-cased, abbreviated when the budget is tight |
{event_raw} |
Same, without the upper-casing |
{counties} |
Where it applies; see below |
{office} |
Issuing WFO |
{ends} |
Local wall-clock end, e.g. 3:45p. Falls back to the alert’s expiry |
{onset} / {issued} |
Same format |
{severity} {urgency} {certainty} |
CAP fields |
{action} |
VTEC action: NEW, CON, EXT, UPG, CAN, EXP |
{phenomena} {significance} {vtec} {etn} |
VTEC parts |
{headline} |
The NWS headline, whitespace-collapsed |
{instruction} |
Lead sentence of the call to action |
{description} |
Lead sentence of the description. Dropped first under pressure |
Times are deliberately terse: 3:45p, not 3:45 PM CDT. The zone is the
reader’s own and at 200 bytes the colon and the suffix are a word of
instruction.
How a message shrinks
An NWS warning is several kilobytes; a Meshtastic text packet holds 237 bytes. Rather than chopping the rendered string, which leaves a fragment ending mid-word, the message is re-rendered at successively cheaper tiers until it fits:
| Tier | Event name | Instruction | Description | Place names |
|---|---|---|---|---|
| 0 | full | lead sentence, ≤110 chars | lead sentence, ≤100 chars | full |
| 1 | abbreviated | ≤60 chars | dropped | qualifiers dropped |
| 2 | abbreviated | dropped | dropped | qualifiers dropped |
Only if tier 2 still overflows, from a long literal in the template or a dozen county names, does it fall back to a hard trim on a word boundary, with an ellipsis so the truncation is visible.
The order is the reverse of how the product is written, on purpose. The instruction is the only part that changes what a reader does, so it is dropped last. What survives at the bottom is hazard, place and time, which is what a weather radio tone alone conveys.
Budgets are counted in bytes, not characters, because every emoji is four of them and most degree and quote characters are two or three.
What {counties} resolves to
In order of preference:
- The labels you gave your own counties. A warning routinely spans
counties you do not care about and the payload lists all of them, so the set
is intersected with yours first. More than two collapse to
Santa Rosa, Baldwin +3. - The NWS name of the alert’s UGC zones, fetched once from
api.weather.govand cached for the life of the process. This is what marine and offshore alerts get, since they can never reach step 1. - The issuing office, only as a last resort. It answers “who wrote this”, which is the least useful thing that could occupy the place slot.
Zone names are written for a forecast product, so they shed their qualifiers as the budget tightens, so “Pensacola Bay Area including Santa Rosa Sound” becomes “Pensacola Bay Area” before the instruction is touched:
⛵ MARINE WARN Pensacola Bay Area, Pensacola FL to Pascagoula MS til 9:15p Move to safe harbor immediately.
All-clears
The all-clear template is fixed:
{emoji}ALL CLEAR: {event} {counties} ended
It goes out at ten below the rule’s own priority, worth sending but never worth displacing a live hazard from a constrained queue, and only for hazards this bridge actually announced. Telling a mesh that a warning it never heard about is over is pure noise, and on a busy evening it would roughly double the traffic.
Emoji and abbreviations
Both tables are built in and merged with anything you add:
emoji:
TO: "🌪"
FW: "🔥"
abbreviations:
Severe Thunderstorm: SVR TSTM
Excessive Heat: EXT HEAT
Abbreviations are applied longest-match-first, so Severe Thunderstorm wins
over Thunderstorm. They are only used from tier 1 down, because a message that fits
keeps the full event name.
