A live terminal dashboard for rtlamr JSON output.
Every utility meter your SDR hears (R900, SCM, SCM+, IDM, NetIDM) gets a row with its latest consumption reading, deltas, and a live sparkline. Pick a row, press Enter, and you get a full-size ASCII chart plus a scrollable table of that meter's readings. Every reading is also written to disk, so a long capture doubles as a permanent history.
Pure Python 3 standard library — no pip installs, no dependencies beyond curses.
rtlamr live meters — q quit ↑/↓ select enter: chart
TYPE ID LATEST DELTA TOTAL AGE N GRAPH
R900 702282128 3,088,108 +18 +142 154s 133 ▄▇
SCM 37603849 469,395 +1 +4 235s 134 ▂▄▆
SCM+ 76074285 475,854 +2 +6 205s 97 ▃▅
R900 706074696 42,642 +40 +2,187 171s 134 ▁▂▄▅▅▆▆█
R900 703681020 543,509 +3 +29 167s 134 ▇
R900 706131386 1,039,463 +7 +185 199s 124 ▁▃▃▃▄▄▄█
SCM 38091037 31,595 +0 +0 263s 125
R900 705818772 702,628 +15 +33 145s 109 ▃▄
R900 702296402 3,833,030 +31 +265 210s 109 ▁▂▅▇▇
SCM 37603885 860,185 +1 +1 181s 154
R900 702282436 2,063,294 +0 +0 200s 121
SCM 33462799 526,763 +1 +1 184s 118
R900 702282012 3,466,975 +0 +0 1.9h 13
SCM 14520144 450,760 +20 +25 322s 14 ▂
R900 702222846 6,001,169 +65 +152 231s 25 ▁▃▄▅
R900 702286900 3,341,656 +73 +277 353s 3 ▆
SCM 33462781 784,059 +1 +1 1.5h 2
R900 702282026 4,692,794 +7 +139 505s 30 ▁▂▂▅▆▇▇█
R900 706192078 509,632 +16 +20 202s 40 ▁▁▂
SCM 38091041 563,132 +1 +1 168s 77
SCM 37601932 270,081 +3 +8 431s 18 ▄▅
R900 702288500 3,588,777 +1 +36 252s 5 █
R900 702207190 2,567,391 +0 +0 2.0h 1
replay finished — press q to quit
| Column | Meaning |
|---|---|
TYPE |
Meter protocol (R900, SCM, SCM+, IDM, NetIDM) |
ID |
Meter/endpoint ID, normalized across protocols (see Protocols) |
LATEST |
Most recent consumption value |
DELTA |
Change vs. the previous different value |
TOTAL |
Consumption since the earliest reading this run has seen for that meter |
AGE |
How long ago that meter was last heard from |
N |
Number of readings received |
GRAPH |
Sparkline of the meter's history (repeated values collapsed) |
R900 706074696 — 134/134 shown [all points] esc: back ↑/↓ scroll g/G top/bottom c: toggle changes-only
42,642 ●●●●●●●●●●●●●●●●●●●
│
│
│
●●●●●●●●●●●●●●●
●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●
│
│
●●●●●●●●●●●●●●
│
│
●●●●●●●●●●●●●●●●
│
│
40,753 ●●●●●●●●●●●
latest: 42,642 last seen: 2026-08-10T20:20:02.399255221Z (205s ago) points: 134 total since first seen: +2,187
TIME CONSUMPTION DELTA
2026-08-10 20:20:02 42,642 +0
2026-08-10 20:18:41 42,642 +0
2026-08-10 20:18:01 42,642 +0
2026-08-10 20:16:38 42,642 +0
2026-08-10 20:15:17 42,642 +40
2026-08-10 20:14:37 42,602 +0
2026-08-10 20:13:16 42,602 +0
rows 1-7 of 134 ↑/↓ scroll · g/G top/bottom (134 received total)
Meters broadcast the same value over and over between actual usage ticks. c filters
both the chart and the table down to the readings where the number actually moved —
here, 134 raw readings collapse to the 10 that changed:
R900 706074696 — 10/134 shown [changes only] esc: back ↑/↓ scroll g/G top/bottom c: toggle changes-only
42,642 ●●
│
│
●
●●
●
│
●
│
│
●
│
●
│
40,455 ●
latest: 42,642 last seen: 2026-08-10T20:20:02.399255221Z (206s ago) points: 134 total since first seen: +2,187
TIME CONSUMPTION DELTA
2026-08-10 20:15:17 42,642 +40
2026-08-10 20:00:08 42,602 +479
2026-08-10 19:45:19 42,123 +149
2026-08-10 19:34:33 41,974 +20
2026-08-10 19:15:40 41,954 +22
2026-08-10 19:00:16 41,932 +335
2026-08-10 18:45:27 41,597 +470
rows 1-7 of 10 ↑/↓ scroll · g/G top/bottom (134 received total)
latest, points, and total since first seen always describe the real underlying
data — the filter only affects what's plotted and listed.
rtl_tcp &
rtlamr -format=json | python3 meter_tui.pyrtlamr's startup banner (SampleRate, DataRate, Preambles, GainCount…) is plain text,
not JSON, so it's skipped automatically — including when you merge stderr in:
rtlamr -format=json 2>&1 | python3 meter_tui.pypython3 meter_tui.py -f clean.jsonl # load it all instantly
python3 meter_tui.py -f clean.jsonl --replay-delay 0.02 # pace it like a live feedtail -f -n +1 live.jsonl | python3 meter_tui.pyFiles are read in the order given, so history is in place before the live stream takes over:
python3 meter_tui.py -f day1.jsonl day2.jsonl day3.jsonl
rtlamr -format=json | python3 meter_tui.py -f old1.jsonl old2.jsonlrtlamr -format=json | python3 meter_tui.py --out-dir output/python3 meter_tui.py -f clean.jsonl --history 1000 # keep 1000 points per meter in memory
python3 meter_tui.py -f clean.jsonl --no-log # don't append to meter_tui_log.jsonl
python3 meter_tui.py -f clean.jsonl --log run2.jsonl # append somewhere else
python3 meter_tui.py --debug-keys # diagnose arrow keys over SSH| Key | Action |
|---|---|
q |
Quit |
↑ / ↓, k / j |
Select a meter row (table) · scroll readings (detail view) |
Enter |
Open the big chart for the selected meter |
c |
Detail view: toggle "changes only" (hide repeated values) |
g / G |
Detail view: jump to newest / oldest reading |
Esc / Backspace / b |
Back to the table |
+ / - |
Keep more / less history per meter (±50, default 300) |
Every reading that parses as JSON and matches a supported meter type is appended
verbatim to meter_tui_log.jsonl. Banner noise is filtered out, but nothing real is
dropped — repeats included — so the log is a faithful capture of what rtlamr sent.
It's line-buffered, so a hard kill can't lose buffered readings. --no-log turns it off.
Additionally fans each reading out to DIR/<ID>_<TYPE>_<YYYY-MM-DD>.json — one JSON
array per meter per UTC day, kept sorted by time:
output/
├── 14520144_SCM_2026-08-10.json
├── 37603849_SCM_2026-08-10.json
├── 702207190_R900_2026-08-10.json
└── 76074285_SCM+_2026-08-10.json
[
{
"Time": "2026-08-10T17:51:34.312530572Z",
"Consumption": 469391
},
{
"Time": "2026-08-10T17:52:35.251882753Z",
"Consumption": 469391
}
]Existing files are loaded and merged on startup, so these accumulate across runs —
a permanent on-disk history independent of --history's in-memory cap.
Overlapping backfill files (or replaying the same capture twice) put duplicate readings
in the log. dedupe.py sorts a log by timestamp and drops any line whose Time field
duplicates an earlier one:
python3 script/dedupe.py meter_tui_log.jsonl # print to stdout
python3 script/dedupe.py meter_tui_log.jsonl -o clean.jsonl # write a new file
python3 script/dedupe.py meter_tui_log.jsonl --in-place # overwrite (keeps .bak)24282 lines read, 0 malformed skipped, 22523 duplicate timestamps removed, 1759 lines written to clean.jsonl
Field names differ per protocol, so ID and consumption are normalized:
| Type | ID field | Consumption field |
|---|---|---|
SCM, R900 |
Message.ID |
Message.Consumption |
SCM+ |
Message.EndpointID |
Message.Consumption |
IDM, NetIDM |
Message.ERTSerialNumber |
Message.LastConsumptionCount |
Records of any other type, or missing an ID or consumption value, are ignored.
Keyboard works with a piped stdin. In rtlamr … | python3 meter_tui.py, fd 0 is
rtlamr's output, not your keyboard — curses would "type" whatever bytes rtlamr sends.
The script dups the piped fd for its reader thread and rebinds fd 0 to /dev/tty, so
keypresses come from the real terminal. Nothing to configure.
Arrow keys over SSH / tmux. Arrow escape sequences are decoded by hand (both the
ESC [ A and ESC O A forms) rather than trusting the remote host's terminfo entry for
$TERM, which is a common failure mode over SSH and through multiplexers. If keys still
misbehave, run python3 meter_tui.py --debug-keys in that exact session to see what your
terminal is actually sending:
Key debug mode -- press Up/Down/Enter/Esc, q to quit
code=259 keyname=KEY_UP KEY_UP
code=258 keyname=KEY_DOWN KEY_DOWN
code=10 keyname=^J
Timestamps. rtlamr emits 0–9 fractional-second digits, which
datetime.fromisoformat doesn't handle consistently across Python versions, so they're
parsed with a dedicated regex. Everything is UTC.
Terminal size. The layout adapts: the sparkline column takes whatever width is left over, and the detail view splits its space between chart and table (table capped at 12 rows, chart gets the rest). Wider terminals show more history; ~100 columns is a comfortable minimum.
"No input" — the script exits with a message if stdin is a terminal and no -f is
given. Pipe something in or pass a file.