Skip to content

Add D-STAR presence alerts (QuadNet/ircDDB source) and local dev mode - #29

Open
jbburns wants to merge 17 commits into
hamalert:mainfrom
jbburns:claude/dstar-alerting-functionality-xadhmf
Open

Add D-STAR presence alerts (QuadNet/ircDDB source) and local dev mode#29
jbburns wants to merge 17 commits into
hamalert:mainfrom
jbburns:claude/dstar-alerting-functionality-xadhmf

Conversation

@jbburns

@jbburns jbburns commented Sep 10, 2026

Copy link
Copy Markdown

Implements the D-STAR side of https://github.com/orgs/hamalert/discussions/17 (presence alerts for D-STAR: "callsign X is active on, or linked to, repeater/reflector module Y"), plus a one-command local development mode that made it testable.

What's new

D-STAR presence source (dstar.js)

  • Tails the public "last heard" logs of QuadNet (openquad.net/ics/ics, HTTP Range tail) and ircDDB (live.ircddb.net:8080/jj3.yaws, line-number poll). Both feeds are normalized into one record shape and go through one shared dedupe cache, so a dual-homed gateway never produces two alerts.
  • Each transmission is classified: voice → active event; a link command in UR (e.g. REF030CL) → linked event (and also active if held long enough to be voice, which is common); info/echo/unlink and other control commands never alert. Directed (callsign-routed) calls are ignored by default (ignoreDirectedCalls).
  • One spot per callsign + node + reflector + event within dedupeInterval (15 min). Node/reflector identifiers are W4HFH-C / REF030-C; the module letter is optional in trigger conditions (REF030 matches all modules).
  • Spots: source: 'dstar', mode: 'dstar', new fields dvEvent, dvNode, dvReflector, plus dvSuffix, dvDuration, comment (TX message), spotter (reporting gateway, falling back to the node's callsign).
  • Frequency and band (dstar_nodes.js): the heard records carry the repeater module but no frequency, so the server keeps a directory of module → registered frequency built from QuadNet's gateway.php (about 5100 modules) and ircDDB's repeater.php (about 490 more), refreshed hourly and persisted like the Club Log cache. normalizeSpot looks up dvNode and sets frequency (then band derives as usual; frequencySource: 'nodelist'). In a live sample all 270 recent QuadNet nodes resolved. If a node is unlisted the band is guessed from the module letter (A 23cm, B 70cm, C 2m) and flagged bandIsGuessed; failing that band is "unknown". npm run dstar-nodes -- <node>... exercises the directory.
  • New matcher conditions dvEvent, dvNode, dvReflector; notification params include the new fields; the simulator accepts D-STAR spots with or without a frequency (resolved from the directory when omitted).
  • Fixes found along the way: the telnet notifier threw on spots without a frequency/spotter and the exception was swallowed silently (D-STAR telnet alerts were dropped); the app notifier is now skipped when APNS/FCM config is absent so the server can start without push credentials; Club Log lookups are skipped when no API key is configured.

Local development mode

  • config_loader.js: every module loads config through it; ./config.js stays the default, HAMALERT_CONFIG=<file> overrides.
  • config-local.js: committed, credential-free config (local Mongo/Redis on non-default ports, no push, no RBN/cluster logins).
  • npm run local-dev (tools/localDev.js): starts clean mongo:7 and redis:7 containers (Docker or Podman), seeds a test user + two D-STAR triggers, optionally builds and starts the hamalert-web dev image (hamalert/hamalert-web PR), prints how to connect, and runs the server. See LOCAL_DEV.md.

Design notes / open points for review

  • Privacy: this is presence, not DX spotting. ircddb.net only publishes users who sent VIS ON; QuadNet publishes everything, so HamAlert exposes nothing that isn't already public. The CQCQCQ-only idea from the discussion doesn't distinguish a QSO on modern D-STAR (both sides keep UR=CQCQCQ through a reflector), so the filter is "voice vs. control traffic" instead; alert wording is "heard on", never "is talking to".
  • Both feeds are volunteer-run; a courtesy note to the QuadNet and ircDDB teams before this runs in production is being sent. Polling is one small HTTP request every 15 s per feed (ircDDB's own live-log page polls every 5 s) plus the two repeater lists once an hour, all with a HamAlert User-Agent.
  • Live coverage: hotspots and gateways on QuadNet/ircddb.net. US Icom G2/G3 repeaters that report only to dstarusers.org are not covered.
  • Thresholds (minVoiceDuration, minVoiceDurationLinkCommand, dedupeInterval) are config values and will want tuning against real traffic.

Testing

  • Offline replay of 8.5 h of QuadNet log: 3603 records → 318 spots (159 active, 159 linked).
  • End to end with npm run local-dev (Docker and Podman): simulated D-STAR spots reach the telnet notifier, Mongo and the Redis stream; the module-less REF030 condition matches REF030-A; live QuadNet/ircDDB spots flow through the pipeline.
  • Web trigger editor and simulate page exercised in a headless browser against this server (see the web PR).

Screenshots

Terminal captures (server startup with both feeds priming, live D-STAR spots, telnet session receiving simulated alerts, dstar-test on the live feed) are attached below.

Companion PRs: hamalert/hamalert-web (trigger editor, docs, Alerts page, dev image) and hamalert/hamalert-app (rendering of D-STAR spots).

🤖 Generated with Claude Code

jbburns and others added 7 commits September 10, 2026 04:11
Adds dstar.js, a receiver that tails the public QuadNet heard log and the
ircDDB live log, classifies transmissions into "active" (voice) and
"linked" (link command) events, drops info/echo/unlink control traffic,
and deduplicates per callsign, node, reflector and event type.

Spots carry source/mode "dstar", no frequency, and new fields dvEvent,
dvNode and dvReflector. The matcher gets matching conditions (node and
reflector match with or without the module letter), the band range is
only derived when a frequency exists, and notification params include
the new fields. tools/dstarTest.js runs the receiver against the live
feeds or a saved log without a database.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rJsJxn47T1Bff6xi6g7ab
…imulator

Node and reflector identifiers are now "W4HFH-C" / "REF030-C" instead of
space-separated, so they survive whitespace splitting in trigger lists,
URL parameters and telnet output. The simulator accepts D-STAR spots
(dvEvent, dvNode, dvReflector) without a frequency, and notification
params include dvSuffix and dvDuration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rJsJxn47T1Bff6xi6g7ab
The telnet "DX de" formatter threw on spots without a frequency or
spotter, and the exception was swallowed silently, so D-STAR telnet
alerts were dropped. Use "DV" in place of the frequency, tolerate a
missing spotter, include event/node/reflector in the comment, and log
notify failures. The app notifier is now skipped when APNS/FCM config
is absent so the server can start without push credentials.

Adds LOCAL_DEV.md and tools/seedLocalUser.js for running the stack
against a local MongoDB and Redis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rJsJxn47T1Bff6xi6g7ab
All modules now load their config through config_loader.js, which
requires ./config.js by default or the file named by HAMALERT_CONFIG.
config-local.js is a committed, credential-free config (local Mongo
and Redis on non-default ports, no push, no RBN/cluster logins, no
Club Log key). tools/localDev.js starts clean mongo:7 and redis:7
containers, waits for them, seeds the test user and D-STAR triggers,
optionally builds and runs the hamalert-web dev image, prints how to
connect, and runs the server; containers are removed on exit unless
--keep is given. Club Log lookups are skipped when no API key is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rJsJxn47T1Bff6xi6g7ab
Podman (docker CLI emulation) refuses short names like "mongo:7" unless
unqualified-search registries are configured; docker.io/library/... works
on both Docker and Podman.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
composer install inside the web container was rewriting the tracked
vendor/ files in the bind-mounted hamalert-web checkout. Mount the
hamalert-dev-web-vendor volume over vendor/ instead, so packages are
installed once and the checkout stays clean; --down removes the volume.
Note that Podman with the podman-docker shim works.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jbburns

jbburns commented Sep 10, 2026

Copy link
Copy Markdown
Author
server-01-local-dev-startup server-02-live-dstar-spots server-03-telnet-alerts server-04-simulated-spots-curl server-05-dstar-test

jbburns and others added 10 commits September 11, 2026 01:20
So the mobile app's feed (which only returns spots matched by an "app"
trigger) has something to show when the app runs in browser dev mode
against the local stack.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dstar_nodes.js keeps a directory of repeater modules and their
registered frequencies from the QuadNet (gateway.php) and ircDDB
(repeater.php) lists, refreshed hourly and persisted like the Club Log
cache. normalizeSpot looks up dvNode there and sets the frequency, from
which the band derives as for any other spot. If the node is unlisted
the band is guessed from the module letter (A 23cm, B 70cm, C 2m) and
flagged bandIsGuessed; failing that band is "unknown". Every D-STAR
spot now has a spotter (the node's callsign when no gateway was
reported). The feed poll interval defaults to 15 s instead of 5 s.
tools/dstarNodeTest.js exercises the directory without a database.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
…limiting

D-STAR presence spots share one frequency per hotspot, so the per-callsign
frequency/mode and band/mode limiters were collapsing distinct events (e.g.
"linked to REF058-E" right after "linked to REF030-C", or "active" right
after "linked" on the same reflector). Those two limiters now also require
the D-STAR event+reflector/node identity to match. Also fixes checkLimit's
simulated-spot shortcut to return the same {limitExceeded, generalLimitExceeded}
shape callers expect, and adds a documented, default-off config.rateLimit.disabled
flag (on only in config-local.js) so npm run local-dev never rate-limits alerts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
…ivity)

QuadNet and ircDDB only ever see repeater/hotspot traffic, never DPlus/REF
reflector activity — zero overlap measured against dstarusers.org's "last
heard" page. Poll it every 30s, diff against a watermark of previously-seen
rows, and emit an "active" spot per row; hotspot/dongle users are reported
by the reflector with no module, so runMatcher() expands a module-less
dvReflector condition to match every module letter.

Also documents the feed in README.md/LOCAL_DEV.md and adds a usage line
for HAMALERT_CONFIG to tools/dstarTest.js's help text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
…flector-module rows

The dedupe key included the repeater node and the reflector module, so the same
transmission reported by QuadNet ("REF030-C via N4EDO-B") and by dstarusers.org
(module row "REF030-C" or dongle row "REF030", both without a node) produced two
alerts; the rate limiter's D-STAR key had the same flaw. Both now key on the
reflector callsign without its module (or the node when there is no reflector).

A dstarusers.org reflector-module row's band text ("REF030 C 2 Meters") is just
the module's label and says nothing about the user's RF band, so it is no longer
used; only repeater/gateway rows ("NS9RC B 440 MHz") carry a band.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
At startup the feeds prime the dedupe cache with up to an hour of old records,
each getting a full dedupeInterval from "now", so a station heard 40 minutes
before a restart stayed silent for 15 minutes after it. Entries now live for
dedupeInterval minus the record's age, and records older than the window are
not cached at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
config.dstar.dedupeInterval = 0 now means no suppression at all; config-local.js
uses it so every test transmission alerts (production keeps 15 minutes). Records
already in the logs at startup are still only used for priming.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
…ongle rows

dstarusers.org's dongle/hotspot login rows (e.g. "REF030 Dongle User") are DPlus logins
reported without a module, not transmissions; when the same user transmits, a proper module
row follows. These are now dropped entirely at the parser (parseDstarusersNode/parseDstarusersRow)
instead of being alerted on as a module-less reflector report, so runMatcher() no longer needs to
expand a bare reflector into every possible module and normalizeSpot()/emitEvent() no longer treat
"no module" as a case to special-case.

The dashboard-scraping module resolver added in e0e9827 is reverted: reflector dashboards can't
reliably supply the module for a merely-listening user (differ per reflector, some don't even
serve the data anymore), and dropping the module-less rows makes the enrichment moot anyway.

Spots now report source as the feed that produced them ('quadnet', 'ircddb' or 'dstarusers')
instead of a single 'dstar' source; mode stays 'dstar' for all three so triggers and the UI can
still treat D-STAR as one thing. dvFeed is removed (redundant with source). Also: dstarusers.org's
Location column is no longer copied into the comment for reflector-module rows (it names the
reflector's own town, not the operator's); repeater rows keep it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
Zero meant the same transmission reported by QuadNet and dstarusers.org alerted
twice; two minutes collapses those (their timestamps differ by up to a minute)
while still letting a repeated test alert again quickly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEzvaE4xxhsuShqGetmVYb
@jbburns

jbburns commented Sep 12, 2026

Copy link
Copy Markdown
Author

Update: third feed, rate limiting, dedupe, per-feed sources

Testing against live REF030/REF058 traffic showed that QuadNet and ircDDB never see DPlus/REF reflector activity: stations connected through hotspots and dongles report to neither network (zero callsign overlap between the two worlds in the same hour). This batch of commits addresses that and a number of things found along the way.

New feed: dstarusers.org. dstar.js now also polls https://www.dstarusers.org/lastheard.php (every 30 s, ~100 KB, fed by DStarMonitor on REF reflectors and Icom gateways). Rows with a module ("REF030 C …", "NS9RC B 440 MHz") become spots on REF030-C / node NS9RC-B. "Dongle User" rows are dropped: they are DPlus logins reported without a module, not transmissions (64 such rows in one hour matched 2 entries in the reflector's own Last Heard; when a dongle user keys up a proper module row follows). Fixture + --dstarusers-file replay in tools/dstarTest.js.

Source names the feed. Spots now have source: quadnet | ircddb | dstarusers (mode stays dstar), consistent with how other sources are named. Triggers with no source condition match all three.

Dedupe. One alert per callsign, event and place (reflector without module, else node) per dedupeInterval, keyed so the same transmission reported by two feeds collapses. Entries are compared on the records' own timestamps, so records primed at startup only suppress within their remaining window (previously a station heard shortly before a restart was silenced for 15 min after it). dedupeInterval: 0 disables it.

Rate limiter. The per-callsign+frequency/band+mode limits now also key on the D-STAR event and place, so "linked to REF030-C" no longer suppresses "active on REF058-E" from the same hotspot (all its events resolve to one frequency). New config.rateLimit.disabled (false in the template) for local development.

Smaller items. Reflector-module rows carry no band (the module label says nothing about the operator's RF band) and no location comment (it was the reflector's town); node-less spots get band: 'unknown' and a spotter fallback; the console spot line prints the place instead of "unknown" for such spots. config-local.js disables rate limiting and uses a 2-minute dedupe window for testing; LOCAL_DEV.md and README updated.

Tried and dropped. Resolving the module of dongle rows from the reflector's own dashboard: the dashboards differ per reflector (REF030 serves a static page over http and a socket.io app over https) and list dongle users as "listening" without a module until they transmit, at which point dstarusers.org reports the module anyway.

Permission-request emails to QuadNet, ircDDB and dstarusers.org are drafted and will be sent before this goes live.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant