Node-RED flows + supporting scripts that automate the VU2CPL amateur radio shack: lightning protection, power control, radio + amplifier telemetry, solar conditions, DXCC alerting, and Pi fleet monitoring.
VU2CPL · Manoj · Bengaluru · MK83TE · Licensed 1993 · 9× BDXCC
A single Node-RED instance on a Raspberry Pi 4B (noderedpi4,
192.168.1.169) hosts 11 flow tabs and one consolidated Shack
dashboard. Hardware talks to Node-RED via:
- MQTT (Mosquitto on the same Pi) — Tasmota power outlets, AS3935
lightning sensor, RPi telemetry, UberSDR receiver metrics.
Authenticated (anonymous disabled 2026-08-21) — role accounts
iot/svc/nodered/ha/displaywith per-account ACLs; seeMQTT_AUTH.md. The read-onlydisplayaccount feeds the AetherSDR panadapter status overlay via a Node-RED humanizer flow (nodered/aether-display/). - Direct TCP/UDP — FlexRadio (4992), DX clusters
- Serial — SPE amplifier (via
spe-remotegateway) - WebSocket — LP-700 power/SWR meter (via the
lp700-servergateway) and Rotor-EZ rotator (via therotator-remotegateway) - HTTP — Open-Meteo, NOAA SWPC, Club Log, RPi fleet agent
Two dashboards, both served by the same Node-RED instance:
/ui— Dashboard 1 (legacy Angular). Dark-themed (base#097479, bg#111111). Original, all features intact./shack— Vue 3 SPA served via uibuilder. 13 cards, all collapsed by default with live summary headers, true responsive layout (CSS column masonry), PWA-installable on iPad / iPhone ("Add to Home Screen"). The primary dashboard going forward. Cards are individually toggleable via aconst CARDS = { … }block at the top ofuibuilder/shack/src/index.js(v-ifper card) — a forker who lacks a subsystem hides its card without deleting any code;rebuild_pi.shStage 13 sets these from a hardware Y/n round. See SHACK_CHANGELOG2026-06-04.
A 2026-05-24 POC of Dashboard 2 (@flowfuse/node-red-dashboard,
served at /dashboard) was retired on 2026-05-26 in favour of
uibuilder + Vue. See SHACK_CHANGELOG 2026-05-26 for the retirement
rationale.
Since 2026-08-25 there is also a Home Assistant satellite view:
the HA "Radio" dashboard (on the household HA instance) mirrors the
whole Vue card set with stock HA cards, across two tabs — Radio
(operating) and Fleet (monitoring). The MQTT-native subsystems —
Power, Solar, House Loads, Lightning, RPi Fleet, GPS NTP, Network,
UberSDR — ride the shack's existing topics through retained
MQTT-discovery configs published by ha_discovery_publish.py, needing
no Node-RED changes at all. The gateway-backed ones (FlexRadio, SPE,
LP-700, Rotator, DXCC, RBN) followed the same day over read-only
Node-RED → MQTT state bridges: per tab, an inject tick → a function
reading that tab's existing context → a retained
shack/<subsystem>/state, touching no flow behaviour. Lightning and
the rotator also carry controls, via HA rest_commands onto the same
HTTP endpoints the dashboards use. One caveat: the DXCC card goes
blank while the DXCC Tracker flow tab is disabled — it has been since
2026-08-27 — because that tab's bridge stops with it.
| Item | Detail |
|---|---|
| Radio | FlexRadio FLEX-6600, RGO One, Icom IC-705 |
| Amplifier | SPE Expert 1.5 KFA |
| Antennas | Hex beam, 160 m inverted-L, 6 m LFA Yagi, Beverage / N6RK / K9AY receive |
| Power | 21 Tasmota-controlled outlets across 5 shack devices, plus 4 house-load Tasmota circuits (FF/GF load, dryer-kitchen, utility — HA's load-shedding relays, toggleable from the Power card) |
| Solar | Deye SG0*LP3 LV 3-phase hybrid inverter, read locally via its Solarman WiFi logger (no cloud, no Home Assistant in the path) |
| Power meter | Telepost LP-700 (USB HID, owned by lp700-server on the Pi) |
| Rotator | Idiom Press Rotor-EZ |
| Lightning | AS3935 sensor on ESP-WROOM-32 bridge → WiFi → MQTT (see vu2cpl-as3935-bridge) + Open-Meteo CAPE polling. Indoor Pi-side daemon retained as standby fallback |
| Awards | 9× BDXCC |
| DXpeditions | VU7T, VU7MS (Lakshadweep), AT5P (Rameshwaram) |
| Grid | MK83TE — Bengaluru |
| Licensed | Since 1993 |
Eleven flow tabs, each handling one logical subsystem. Detailed node
IDs, function bodies, and operational quirks live in
CLAUDE.md. The summaries below are "what + why".
Auto-disconnects the antenna and radio when lightning is detected within a configurable threshold (default 25 km). Two strike sources:
- Open-Meteo CAPE polling every 5 min — synthesises a strike distance from CAPE values and WMO weather codes (95/96/99 = thunderstorm).
- AS3935 chip sensor — rated ~40 km range. Bridged onto MQTT by an
ESP-WROOM-32 (
vu2cpl-as3935-bridge, v0.2.0). Indooras3935.serviceon the Pi is retained as standby fallback. Installed outdoors 2026-07-31 (sealed enclosure, 18650+TP4056+solar power chain, shade mount, TUN_CAP retuned) — off the bench and back to its full rated range.
A vertical BYPASS switch on the dashboard suspends auto-disconnect for 120 minutes (force-reconnects ant + radio on activation, never survives a Node-RED restart). The alert banner always shows current state with a muted "Last: …" recap after 30 s of silence. Reconnect fires automatically after a configurable clear period (default 20 min) once the storm passes.
Event logging: Strike events (including disconnect/reconnect transitions
and sensor status changes) are persisted to nr_lightning_events.jsonl in
the flows directory. The path is configured in the Init Defaults node
(ec1fd4dece8c4dc0) on the Lightning tab and survives Node-RED restarts.
UI lives on the main Shack tab as the Lightning Protection group (width 12, order 9).
Reads the SPE Expert 1.5 KFA over FTDI serial at 250 ms intervals
(76-byte fixed frame, checksum + wraparound validation). Power-on is
sent over the WebSocket and performed by the spe-remote gateway's
DTR/RTS toggle (spe/power_control.py); power_spe_on.py is a
standalone fallback for when the service is down.
Output-power bar — auto-ranging. The bar's full-scale value is
picked from a ladder (5 / 10 / 25 / 50 / 100 / 250 / 500 / 1k / 1.5k
/ 2k / 5k W) — the smallest rung that still contains the current
reading. So a 3 W tune carrier fills ~60 % of a 5 W bar, a 700 W blast
fills ~70 % of a 1 k W bar, and a clean 1.5 kW key-down sits on the
1.5 k W rung. Matches the legacy /ui SPE Panel behaviour with a
finer low-end ladder (so low-power tuning is actually readable). The
amp's L / M / H power-level setting is still shown separately as a
tile (PWR LVL / colour-coded amber/red for Middle/Maximum), but
does not drive the bar scale — auto-ranging tracks the actual
output regardless of the amp's selected level cap.
TUNE control + LED. The Tune button starts an ATU tune cycle, and
its colour mirrors the amp's front-panel TUNE LED — the button stays
neutral while the LED is off and turns solid amber (with a glow, and a
⚡ TUNE chip in the Vue card's collapsed header) while the amp is in
TUNE mode. The LED state comes from the amp's RCU LCD stream (decoded in
the spe-remote gateway), not the CSV status frame, so it's live
whenever the gateway is running.
Power meter modes — RAW / AVG / PEAK. A pill toggle by the Output
Power bar (both dashboards) switches what the bar shows: RAW (the
live ~25 Hz reading), AVG (a ~1 s smoothed average), or PEAK (a
peak-hold that pins the highest reading ~2.5 s then eases back down at a
constant 600 W/s). AVG and PEAK are derived in the spe-remote gateway
(p_out_avg / p_out_peak); each pill appears only once the gateway is
sending that field, so an older gateway simply shows RAW. PEAK is a
sampled peak (25 Hz) — good for catching sustained SSB/CW crests, not
a true envelope-PEP wattmeter. The chosen mode persists in the browser
and is shared between /ui and /shack.
TCP API to the FLEX-6600 at 192.168.1.148:4992, plus UDP discovery.
Per-slice state (frequency, mode, RX/TX, meter levels) aggregated
into flexState flow context and rendered into the FlexRadio panel.
clientHandleMap is built from the discovery message so we can label
slices with the GUI client station name.
Transmit-state semantics — two distinct concepts that look the same:
slice.tx == 1and the derivedslice.isTx == 1mean "this slice is the TX-armed slice" — i.e. which slice would transmit if you keyed up. It does NOT mean the radio is currently transmitting. (Aggregator handles split mode where both slices havetx==1: it picks the one withactive==0as the true TX.)flexState.txstate(string) is the actual radio state —"READY"while receiving, something else (e.g."TRANSMIT") while keyed.
The new Vue /shack dashboard uses txstate for the live RX/TX
indicator in the FlexRadio card header. The old D1 panel mixes the two
in places but renders correctly because of the surrounding context.
Lesson: if you add any "is the radio transmitting right now?" logic
to a flow node or template, key it off flexState.txstate, not
slice.isTx.
5 Tasmota devices, 21 outlets total. Each outlet has its own dashboard
tile; the panel template is fully driven from MQTT stat/<device>/POWER<n>
state messages. Outlets default to off on page-load to avoid the
"stale ON" bug; a 30 s poll loop pings every outlet across every
device so the dashboard never lies for more than ~30 s.
The 16 A master switch publishes energy data every 30 s for shack-wide consumption monitoring. The rotator outlet has a 5 min auto-off timer with idempotent retrigger guard + 10 s cooldown to prevent reset loops.
Below the 16 A voltage/current/power/today line, both dashboards carry
two further rows (added 2026-08-25): a solar row of two half-width
tiles — Grid (ON/OFF plus the three input phase voltages) and
Battery (state of charge, with charge state and power on one line)
— read directly from the Deye hybrid inverter's Solarman logger by
solar_inverter_mqtt.py (1-min cron → retained
shack/solar/inverter, and since 2026-08-25 an append-only
~/grid_voltage.csv — see Grid voltage log below) — and a house-loads row showing the four
house Tasmota circuits (FF Load, GF Load, Dryer Kit, Utility): relay
ON/OFF, live watts, and today's kWh. Each house tile is also the
switch — click, confirm, and it toggles the relay via
POST /power/house-toggle. The relays belong to Home Assistant's
battery-SOC load-shedding automations, which may override a manual
toggle on their next threshold rule.
solar_inverter_mqtt.py also appends every reading to
~/grid_voltage.csv (ts_iso, ts_epoch, status, l1_v, l2_v, l3_v, grid_on, batt_soc, batt_p_w). Nothing else in the shack stores a
voltage time series — the MQTT message is retained, i.e. last value
only — so this file is the sole record, and it is not regenerable.
Added 2026-08-25 to evidence nightly over-voltage to the supply
utility. Failed reads are logged as status=read_fail rather than
left as a bare gap, so "the logger was busy" stays distinguishable
from "the site lost power".
grid_voltage_report.py renders it into a
Markdown report plus an SVG chart — daily per-phase min/mean/max, a
night-window section, over-voltage episodes, and supply interruptions.
The chart uses a fixed 200–300 V scale with the night window shaded
and supply interruptions drawn as red "BESCOM off" bands (the phase
traces break across them rather than plunging to the inverter's 0 V
reading). Pure stdlib, runs on the Mac or the Pi:
python3 grid_voltage_report.py --csv ~/grid_voltage.csv --out report.md --svg chart.svgTwo caveats the report states on its own front page, because both change what the numbers prove: the inverter's grid input is downstream of the servo stabiliser (so readings are true incoming mains only while the stabiliser is in bypass), and a hybrid inverter exporting PV lifts the voltage at its own terminals (so daytime figures are arguable, while the night window has neither PV nor export).
Currently disabled (2026-08-30, operator request). The cron entry was removed once the overnight experiment concluded; the script, its state file and the env-file credentials are all still in place. Re-enable with one line:
* * * * * /usr/bin/python3 /home/vu2cpl/stabiliser_watch.py --cronIt ran correctly for its ~34 hours: 7 outage alerts + 7 recoveries, all real, no false positives.
stabiliser_watch.py tails the same CSV once a
minute from cron and pushes a Telegram alert when the supply state
changes. It exists because the question the log is answering — do the
nightly outages resume now the stabiliser is back in circuit? — comes
due while the operator is asleep. Three conditions are separated,
because they implicate different things:
| State | Meaning | Signature |
|---|---|---|
🔴 outage |
Grid absent — the nightly-cut pattern under test | grid_on=0 or logger unreachable, 2+ samples |
🟠 dropout |
Stabiliser no longer in circuit (tripped to bypass or switched out) | vmax above 245 V for 5+ consecutive samples |
⚪ stale |
CSV stopped growing — the watchdog is blind | no new row for 6 min |
A ✅ recovery alert follows when it returns to regulating. State lives
in ~/.stabiliser_watch.state so each transition alerts once rather
than every minute.
Why voltage and not phase spread. Spread looks like the obvious discriminator and is not: across the bypass window it ranged 0.6–22 V and overlapped the regulated range completely. Absolute voltage separates them better — regulated output sits near 230 V while raw mains ran 250–264 V — so the threshold keys on vmax.
Why the confirmation count does the real work. The first hour of regulated output peaked at 242.2 V, leaving only 3.9 V of margin under the 245 V threshold across a 3-sample run — too thin to stake a 3 a.m. alert on. Measured against the real data, requiring 5 consecutive samples lifts the worst sustained regulated reading to 238.0 V (+7.0 V margin) while costing almost no detection: night coverage over the bypass window falls 96.7% → 95.5%, because genuine dropouts lasted many minutes. Raising the threshold instead would have been far more expensive — 250 V drops night coverage to 75.9%.
Settled 2026-08-30. Across the full regulated era (1966 live samples, including a night with no PV export) vmax peaked at 242.7 V and nothing crossed 245 V, so no false 🟠 fired — and none would have at the old 3-sample setting either. The retune was precautionary rather than necessary, but the ceiling is now measured rather than guessed: regulated output tops out around 243 V. Re-measure both constants only if the stabiliser is reconfigured to regulate higher.
Credentials. Token and chat-id resolve in three steps: the process
environment, then the shack env files (/etc/default/vu2cpl-shack,
then ~/.config/vu2cpl-shack.env, which wins — the same precedence
monitor.sh uses), then the running Node-RED process as a fallback.
The env-file step was added 2026-08-29 on the operator's instruction:
before it, alerting depended on Node-RED being alive, so the watchdog
went silent exactly when the shack was least healthy. flows_guard.py
got the same treatment in the same pass.
The cost is a bot token at rest in a mode-600 file inside a 700 home directory. Neither env file is in the repo, and this repo is public — keep it that way. The env file is created by hand on a rebuild; see REBUILD_PI.md.
python3 stabiliser_watch.py --status # current state, no alert
python3 stabiliser_watch.py --test # verify the Telegram pathThe bypass window is closed. The stabiliser was in bypass from
2026-08-25 13:11 IST (when logging began) to 2026-08-29 10:09
IST, when the operator brought it back online. Only that slice of
grid_voltage.csv is true incoming mains; every row from 10:10 on
2026-08-29 onward is the stabiliser's regulated output (~232 V,
all three phases within ~1 V of each other — the tight convergence is
how you spot the boundary in the raw file, which carries no
stabiliser-state column). Pass --bypass to label the window in the
report, and filter the CSV to it before quoting any over-voltage
figure to the utility.
What the sealed window showed, and what the follow-up experiment did. Across 25–29 Aug on true mains: 24.5% of samples above 253 V, peak 264.2 V, nothing near the 270 V stabiliser trip, and eight interruptions none of which fell overnight — against a Solarman cloud export that put 89% of 49 interruptions between 22:00 and 06:00 in the preceding month. That made the stabiliser look like the thing tripping. Putting it back in circuit on 29 Aug was the test, and the night of 29→30 Aug ran 480/480 samples with zero interruptions — the pattern did not resume. Restoring the suspected cause did not restore the effect, so the nightly-trip hypothesis is substantially weakened; the cuts appear to have stopped around 25 Aug for reasons outside the shack. The claims that still hold are chronic over-voltage and frequent interruptions by day (six across 29–30 Aug), and those are what a utility case should rest on.
For the period before local logging began, the only record is what the
inverter had already uploaded to the manufacturer's cloud.
deye_history_report.py parses Solarman's
"PlantsDetails-History" .xlsx exports (5-minute telemetry, one file
per day) into a supply-interruption report, a day-by-day timeline SVG,
and a CSV:
python3 deye_history_report.py --dir "~/Downloads/deye data" \
--out outages.md --svg outages.svg --csv outages.csvThat export carries no voltage channel, so it evidences outages rather than over-voltage. An interruption is only counted where grid power is zero and the battery is discharging to carry the house (or there is no solar to do so) — a full battery with PV covering the load also draws nothing from the grid and would otherwise be miscounted. The battery's state of charge falling is independent confirmation that supply was genuinely absent, which is the first thing a utility will question.
Polls NOAA SWPC every 15 min for SFI, K-index (8 × 3-hour planetary Kp), A-index (planetary Ap), Scales (R/S/G), GOES X-ray flux, and prop.kc2g.com for MUF / foF2 at home grid. Three ui-level gauges + band-condition heatmap.
Migrated from direct USB-HID to a WebSocket client of the
VU3ESV/LP-700-Server
gateway running on the same Pi. The gateway owns /dev/hidraw*;
Node-RED, the embedded web client, and any future Mac SwiftUI app
all subscribe to telemetry concurrently. Updates at ~25 Hz. Buttons
emit JSON command verbs (channel_step, range_step, etc.).
Idiom Press Rotor-EZ on FTDI serial at 4800-8N1. Heading display +
preset compass-rose buttons. Since 2026-06-06 the serial port is owned by
the rotator-remote gateway
(rotator-remote.service, :8090) — the Node-RED tab is a thin WebSocket
client, so the browser, a future Mac app, and any other client share the
rotor without serial contention. Power stays on Tasmota/MQTT.
Disabled since 2026-08-27 (operator choice): the flow tab is
disabledin flows.json and the Vue card hidden viaCARDS.dxcc. Nothing is deleted — flip both flags back to re-enable.
DX cluster spot monitoring with real-time alerts for new entities,
bands, and modes against Club Log worked/confirmed data. Subscribes
to four cluster sources (VU2CPL, VU2OY, KIEL, N2WQ); resolves prefix
via cty.xml; classifies against bands[mk] === 2 confirmed criterion;
fires alerts to dashboard, MQTT, FlexRadio, and Telegram.
Full reference: DXCC.md /
DXCC_Tracker_README.pdf.
Subscribes to rpi/<hostname>/{cpu,mem,temp,disk,uptime,ip,status}
MQTT topics. Builds a fleet-status panel with one card per host;
alerts on CPU >90 %, Temp >75 °C, Mem >90 %, Disk >90 %. Reboot /
Shutdown buttons send POST /reboot or /shutdown to each host's
rpi-agent.service listening on :7799.
Currently monitoring noderedpi4, openwebrxplus, gpsntp. Two more
Pis + the Home Assistant Pi pending onboarding.
A separate Chrony / GPS Time Server card on the Shack Monitoring
tools dashboard tab (Network Monitor group) shows live status of
gpsntp.local — the stratum-1 GPS-disciplined NTP server. Single
ui_template driven by retained MQTT topic shack/gpsntp/chrony
(cron-published every minute by the Pi-side
pi-gps-ntp-server
project). Threshold-aware (orange when offset / dispersion / skew
exceeds tolerance, ref source ≠ PPS, or fix < 3D).
Per-Pi onboarding runbook: DEPLOY_PI.md.
Pings local infrastructure (router, AP, key servers) and the broader internet (DNS, well-known anycast hosts) at intervals; surfaces latency + reachability on a status grid. Used to diagnose whether a station-wide problem is local network or upstream.
Reverse Beacon Network monitoring — tracks how the VU2CPL signal is
being heard worldwide via skimmer reports. Calibration data fetched
from sm7iun.se/rbnskew.csv every 6 h.
├── flows.json Main Node-RED flow (canonical source)
│
├── as3935_mqtt.py AS3935 chip daemon — standby fallback (ESP32 bridge is primary publisher)
├── as3935_tune.py LC-tank TUN_CAP sweep helper
├── rpi_agent.py HTTP reboot/shutdown agent (→ rpi-agent.service)
├── rpi-agent.service systemd unit for rpi_agent
├── monitor.sh MQTT telemetry cron (every minute)
├── solar_inverter_mqtt.py Deye inverter → retained shack/solar/inverter + ~/grid_voltage.csv (1-min cron)
├── grid_voltage_report.py grid_voltage.csv → Markdown + SVG evidence report (pure stdlib)
├── stabiliser_watch.py Stabiliser trip / outage / dropout → Telegram (1-min cron, pure stdlib)
├── deye_history_report.py Solarman xlsx cloud exports → supply-interruption report + timeline (pure stdlib)
├── monitor_redpitaya.sh Fleet telemetry variant for the Red Pitaya (Alpine/BusyBox, Zynq XADC temp)
├── flows_guard.py Stale-tab wipe tripwire (git pre-commit hook + 1-min cron w/ Telegram alert)
├── flows_guard_middleware.js Server-side deploy rejection (httpAdminMiddleware in settings.js)
├── power_spe_on.py SPE amp FTDI DTR/RTS power-on helper
├── enable_file_context.sh One-shot Node-RED file context store enabler
├── rebuild_pi.sh Automated bare-metal rebuild script (paired with REBUILD_PI.md)
│
├── as3935.service systemd unit for as3935_mqtt.py (installed but disabled; re-enable as fallback if ESP32 fails)
│
├── README.md This file (umbrella overview)
├── DXCC.md DXCC Tracker reference
├── DXCC_Tracker_README.pdf DXCC reference (rendered PDF)
├── CLAUDE.md Operator deep-reference: node IDs, gotchas, runtimes
├── REBUILD_PI.md Disaster-recovery runbook: blank SD → working shack
├── DEPLOY_PI.md Per-host fleet-member onboarding runbook
├── FORK_GUIDE.md Customisation runbook for forking to a different station
├── HANDOVER.md Session pickup notes
├── SHACK_CHANGELOG.md Dated changelog of non-DXCC tab changes
├── SHACK_CHANGELOG.pdf Changelog rendered PDF (always in sync with .md)
└── LICENSE MIT
| Doc | Audience | When to read |
|---|---|---|
| README.md | Anyone | First — what is this repo |
| CLAUDE.md | Operator (and the LLM context) | Looking up a node ID, broker port, install command |
| REBUILD_PI.md | Operator (disaster recovery) | "The shack Pi died — rebuild from blank SD card" |
| DEPLOY_PI.md | Operator | Onboarding a different Pi as a fleet member (telemetry + reboot agent only) |
| FORK_GUIDE.md | Different operator forking the repo | "I'm not VU2CPL but I want to run this stack at my own QTH" — per-site customisation runbook (callsign, grid, MQTT broker, hardware substitutions, /shack rebadging) |
| DXCC.md / PDF | Operator | Working on the DXCC tab specifically |
| SHACK_CHANGELOG.md / PDF | Future-self | "What did I change on day X" |
| HANDOVER.md | New session pickup | "What was I in the middle of" |
After deploying changes in the Node-RED editor:
ssh vu2cpl@192.168.1.169
cd ~/.node-red/projects/vu2cpl-shack
nrsave "<description>" # alias: git add flows.json && git commit -m
git pushPull on the Mac side:
cd ~/projects/vu2cpl-shack
git pullFor DXCC tab changes the commit additionally regenerates the tab extract — see DXCC.md.
MIT — see LICENSE. Third-party code embedded in
flows.json (notably from
VU3ESV/LP-700-Server)
retains its own terms.
73 de VU2CPL