istrain-public: from-scratch build of a passive RF train detector
Public community release. The complete working system — DSP + decoders (scripts/), web dashboard + API (dashboard/), container stack (docker/), config templates (config/) — plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3 (PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# iSTRAIN — build stages
|
||||
|
||||
**Primary goal:** detect a train blocking *your* crossing, from passive RF alone.
|
||||
**Secondary:** capture and learn — radios, trains, hardware, software. Not a toy.
|
||||
This file renders in the dashboard's checklist panel; edit it to track your own build.
|
||||
Full instructions: **APOCALYPSE-EDITION.md**.
|
||||
|
||||
## The plan · two radios, two bands
|
||||
- **Radio A (a Blog V4 is ideal) → rail VHF voice 160–162 MHz** — RTLSDR-Airband demodulates your
|
||||
pinned road/detector channels at once (your subdivision's AAR channel is the mission).
|
||||
- **Radio B (any RTL-SDR) → EOT-system UHF hop** — 452.9375 (head/BOT) ⇄ 457.9375 (EOT/tail),
|
||||
retune-in-place; ±12.5 kHz DPU neighbors watched inside each dwell.
|
||||
|
||||
## Stage 0 · Host & dongles
|
||||
- [ ] Linux host with Docker; base tools installed (rtl-sdr, ffmpeg, python3-numpy)
|
||||
- [ ] DVB driver blacklisted on the host (`config/blacklist-rtl-sdr.conf`); `lsmod | grep dvb` empty
|
||||
- [ ] Dongles serialized (`rtl_eeprom -s …`) so services pick them by name
|
||||
- [ ] NOAA weather RX proves the antenna→dongle→software chain end to end
|
||||
|
||||
## Stage 1 · Your crossing
|
||||
- [ ] Railroad + subdivision identified; road channel + neighbors found (cross-checked on 2 sources)
|
||||
- [ ] Nearby defect-detector mileposts noted (your early-warning tripwires)
|
||||
- [ ] `config/istrain.conf` edited with YOUR voice channels
|
||||
|
||||
## Stage 2 · Bring-up
|
||||
- [ ] DSP selftests pass (`iq_hop.py --selftest`, `iq_channelize.py --selftest`)
|
||||
- [ ] Stack builds and runs (`docker compose --profile radio --profile radio1001 up -d`)
|
||||
- [ ] `level.json` refreshing; clips landing; dashboard live at `:3456`
|
||||
- [ ] First EOT decode on a real train (unit ID + brake psi + motion) 🎉
|
||||
|
||||
## Stage 3 · Ears & polish
|
||||
- [ ] Whisper transcription wired (comms band-pass first, VAD on — see APOCALYPSE §7)
|
||||
- [ ] Squelch tuned to your noise floor (not a textbook number — APOCALYPSE §8)
|
||||
- [ ] Standing carriers (fixed wayside boosters) identified and carded, not chased
|
||||
|
||||
## Stage 4 · If you expose it publicly
|
||||
- [ ] Read APOCALYPSE §10 (security): presentation-node read-only mode, no PUSH_KEY, proxy auth
|
||||
- [ ] Antenna moved high and away from your computers — the #1 range lever
|
||||
|
||||
## The habit that makes it work
|
||||
- [ ] Keep one append-only, numbered observations log. Record findings AND retractions.
|
||||
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>iSTRAIN — railroad VHF watch</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🚆 iSTRAIN <span class="sub">Lassiter Creek · <span id="ver" title="commit this dashboard is serving (* = uncommitted local changes)">—</span> · railroad VHF 160–162 MHz</span></h1>
|
||||
<div class="hdr-right">
|
||||
<div class="controls">
|
||||
<button id="btn-refresh" class="btn" title="re-pull data">↻ refresh</button>
|
||||
<button id="btn-reset" class="btn reset" title="full reload">⟲ hard reset</button>
|
||||
</div>
|
||||
<div class="hits">
|
||||
<div class="stat"><div class="num" id="hits-unique">—</div><div class="lab">unique</div></div>
|
||||
<div class="stat"><div class="num" id="hits-total">—</div><div class="lab">total hits</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- live: the headline inference — isTRAIN? — mounted from panels/banner/ (above all cards) -->
|
||||
<div id="istrain-banner"></div>
|
||||
|
||||
<main>
|
||||
<!-- FIRST PANEL: the "AIS-equivalent" for trains — dispatch / yard data.
|
||||
In-band source now (defect-detector & dispatcher voice on the road channel, transcribed);
|
||||
ATCS/CTC (900 MHz) was evaluated and dropped (FCC-retired → encrypted PTC; our 900 was a spur). -->
|
||||
<section id="dispatch" class="panel dispatch">
|
||||
<h2>Dispatch / Yard <span class="tag">rail-voice captures — the "AIS" for trains</span></h2>
|
||||
<div class="placeholder">
|
||||
<b>Airband is watching 5 channels on 1002.</b> Captured transmissions (squelch-gated clips →
|
||||
whisper transcripts) will list here newest-first: channel, time, and the milepost / axle-count /
|
||||
speed read-outs from detector call-outs. ★ <b>160.980 NS Dayton District</b> is the mission channel.<br>
|
||||
<span class="dim">Live clips appear in the <b>Captures</b> panel (one per transmission, playable);
|
||||
transcription (whisper) still pending.</span><br>
|
||||
<b>scan status:</b> <span id="airband-status">service <code>istrain-airband</code> — live status & controls in the <b>Supervisor</b> panel below</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- captures panel RESTORED 2026-07-10 — now lives below Community Feeds (novelty tier), see there. -->
|
||||
|
||||
<!-- live: Trains — per-train head+tail cards from decoded EOT (lifecycle + crossing inference) -->
|
||||
<section id="trains" class="panel detect"></section>
|
||||
|
||||
<!-- activity panel SHELVED 2026-07-02 — voice ACTIVITY stays a signal in the correlate graph
|
||||
(which reads heard.jsonl); the dedicated activity panel stays down. -->
|
||||
|
||||
<!-- live: EOT presence on 457.9375 (dongle 1001) — a burst = a train's rear unit is near -->
|
||||
<section id="eot" class="panel detect"></section>
|
||||
|
||||
<!-- live: multi-band train-activity correlation — co-occurring activity across voice/EOT/(head-end) -->
|
||||
<section id="correlate" class="panel detect"></section>
|
||||
|
||||
<!-- scaffold: BOT / head-end presence on 452.9375 — lights up when the 452 hop feeds /api/bot -->
|
||||
<section id="bot" class="panel detect"></section>
|
||||
|
||||
<!-- scaffold: mid-train DPU (~900 MHz) presence — lights up when researched + hopped, /api/midtrain -->
|
||||
<section id="midtrain" class="panel detect"></section>
|
||||
|
||||
<!-- live: signal meter — bouncing dBFS bar for the band the hop is currently on (/api/level) -->
|
||||
<section id="live" class="panel"></section>
|
||||
|
||||
<!-- DEFECT DETECTORS — static reference from defectdetector.net (one-time import by id, not a poller) -->
|
||||
<section id="detectors" class="panel detect">
|
||||
<h2>Defect Detectors <span class="tag">reference — your subdivision (example)</span></h2>
|
||||
<div class="placeholder">
|
||||
Wayside detectors near your crossing (example: MP 148.99), all on your mission channel (example <b>160.980</b>). The
|
||||
nearest <b>talks on every train</b> — its call-out is the unambiguous "train through" event.<br>
|
||||
• <b>CJ 146.4 ★</b> — <b>nearest</b>, ~2.6 mi east (New Rome OH). HBD-DED · <b>talks every train</b>
|
||||
(Talk-On-Defect-Only: No) → routine call-out on <b>westbounds</b> · <a href="http://map.defectdetector.net/?id=8519">record</a><br>
|
||||
• <b>CJ 144.7</b> — ~4.3 mi east, high-car (over-height) — defect-only<br>
|
||||
• <b>CJ 157.8</b> — ~8.8 mi west, warns eastbounds — type TBD<br>
|
||||
<span class="dim">Source: defectdetector.net — static records (location/type/freq/audio), pullable per detector by id. One-time import, not a live poller.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- COMMUNITY FEEDS — live audio (when up) -->
|
||||
<section id="feeds" class="panel detect">
|
||||
<h2>Community Feeds <span class="tag">live audio — when up</span></h2>
|
||||
<div class="placeholder">
|
||||
• <a href="https://www.broadcastify.com/listen/feed/26042">Broadcastify — South Central Ohio Railroads</a>
|
||||
— the one Franklin County rail feed; monitors our <b>160.980</b> (independently validates the channel). <span class="dim">Often offline, ~0 listeners.</span><br>
|
||||
<span class="dim">Their rooftop hears more than our desk whip — "they catch it, we don't" = range, not broken. Someday: we become the local contributor.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- captures: RESTORED 2026-07-10 (was shelved 07-02 "return with the new antenna" — it arrived,
|
||||
same night the whisper pipeline went live). The public node serves VOICED clips only (pushed
|
||||
from the VM: push-to-nas _push_clips → /api/push-clip, self-pruning ~120 files); 🗣 rows carry
|
||||
the whisper transcript. Novelty tier below Community Feeds. -->
|
||||
<section id="captures" class="panel detect"></section>
|
||||
|
||||
<!-- SIGNAL FLOW — hand-maintained ASCII map: frequency -> dongle -> approach, by band.
|
||||
A learning tool. Keep it current as the build iterates (LIVE/TEST/NEXT/LATER tags). -->
|
||||
<section id="flow" class="panel flow">
|
||||
<h2>Signal Flow <span class="tag">how it fits together — the air ▸ the dongles ▸ meaning</span></h2>
|
||||
<pre class="flow-chart">
|
||||
iSTRAIN — SIGNAL FLOW the air ▸ the dongles ▸ meaning
|
||||
mission: is a train blocking YOUR crossing? (set your channels in config/istrain.conf)
|
||||
legend: ★ TCXO (precise) [nnnn] = dongle serial ─▶ data path
|
||||
updated 2026-06-28: voice live (1002); 1001 WEIGHTED hop 452/457 (clock-step-proof); EOT decoded LIVE (ID+brake+motion); HOT head-end ADDRESS cracked → head+tail join; MID standing carrier ID'd = 457.925 EOT booster, carded. ATCS dropped.
|
||||
updated 2026-07-05: EOT decode is TWO-PASS — off-frequency emitters (±800 Hz, ~100 ms bursts) now decode
|
||||
via adaptive tones + fuzzy sync (O-11) → dwelling trains identified w/ brake psi: held ~60 = stopped
|
||||
solid · bled to 0 + head polling = CUT & standing (crossing possibly open). Booster floor subtracted
|
||||
from contacts & correlate (O-9).
|
||||
updated 2026-07-18: THE WHOLE RIG MOVED TO THE MILL — both dongles + capture + decode + transcription
|
||||
+ this site now run as one Docker (Dockge) stack on a TrueNAS box; the old VM runs zero radio
|
||||
services; data is born where it's served. Voice transcription is LIVE (not planned — see below).
|
||||
────────────────────────────────────────────────────────────────────
|
||||
|
||||
PRIMARY ░░ RAIL VHF VOICE — 160–162 MHz · AAR · NBFM, unencrypted ░░
|
||||
└ defect detectors ("MP 123, no defects, 45 axles, 60 mph") + dispatcher
|
||||
─▶ RTLSDR-Airband (multi-ch) ─▶ ffmpeg comms-filter ─▶ faster-whisper (small.en + VAD,
|
||||
LIVE on the Mill since 07-10) ─▶ transcripts.jsonl — "a train is moving through," in words
|
||||
▸ the most direct, gettable "is MY crossing blocked" signal
|
||||
▸ proven 2026-06-16: same band as NOAA 162.550 — RX confirmed here
|
||||
▸ CONFIRMED channel (2026-06-18): 160.980 (ch58) NS Dayton District, MP 148.99;
|
||||
detectors CJ 144.7 / 157.8 announce here. Plan + sources → CHANNELS.md
|
||||
▸ first intelligible speech 2026-07-10, the night the tuned antenna went in:
|
||||
detector CJ at MP 146.4, transcribed (O-22)
|
||||
stick: [1002 ★] Blog V4 — Airband demods the 5 pinned channels at once (160.86–161.19 @ 1.024 MS/s)
|
||||
antenna: tuned railroad-band vertical (upgraded 07-10 — ≈2.4× the desk whip's real content;
|
||||
office placement, rooftop mast = the standing next lever)
|
||||
|
||||
SECONDARY ░░ EOT-SYSTEM UHF — 1001 WEIGHTED HOP (452 ⇄ 457) · 1200-baud FFSK ░░
|
||||
└ a train is a chain of RF emitters front-to-back; 1001 hops to watch both ends:
|
||||
▸ 457.9375 EOT/FRED — the REAR unit; DECODED live: ID · brake · MOTION (rolling/stopped) ─▶ eot.jsonl + clips
|
||||
decode is TWO-PASS (07-05): exact fast path + adaptive/fuzzy escalation for the
|
||||
off-frequency short-burst emitters that used to read "strong but undecodable" (O-11)
|
||||
▸ 452.9375 BOT / head-end (HOT) — the FRONT unit; frame RECOVERED + addressed-unit DECODED ─▶ bot.jsonl
|
||||
▸ hop: iq_hop.py — retune-in-place, BOT 10s / EOT 10s (rebalanced 07-07: the head = the join + the EARLY WARNING; was 7/14 EOT-favored before the HOT decoder existed)
|
||||
▸ EOT decode LIVE 2026-06-27 — real trains identified; brake-release + motion=1 = departed (crossing cleared)
|
||||
▸ HOT address CRACKED 2026-06-28 from our own air (invert · sync 0110101010 · bit-49) → head↔tail JOIN:
|
||||
a HOT frame addressing a card's own EOT = confirmed complete consist. Command/type + BCH still open.
|
||||
stick: [1001] generic — drifts, ppm-calibrate for a clean FSK decode; 450 band clean (no filtering)
|
||||
antenna: ~6" vertical — ¼λ ≈ 6.4" @ 457, well-matched
|
||||
▸ MID / DPU — ±12.5k off both centers (452.925/.950, 457.925/.950), watched in-hop → midtrain.jsonl
|
||||
found ░ a STANDING carrier on 457.925 = an FCC railroad EOT SIGNAL-BOOSTER (fixed wayside repeater, not a
|
||||
train); carded as a STANDING SIGNAL. A booster here ⇒ marginal head↔tail RF — likely why our range is short.
|
||||
|
||||
SYNTHESIS ░░ the mission is DETECT a train, not decode one signal ░░
|
||||
└ a train lights several bands at once — co-firing in the same 15-min slot = a candidate,
|
||||
even when each is too weak to decode: 160.980 voice + 452 BOT + 457 EOT (+ ±12.5k DPU,
|
||||
booster-floor subtracted so the fixed carrier can't fake a band — O-9)
|
||||
▸ correlate panel = week heatmap (one row/band, aligned bins) — read DOWN a column
|
||||
▸ BOT/mid/EOT strips below = the RECENT ZOOM (1-min bins, last 6 h) — correlate's magnifier
|
||||
▸ live meter = bouncing dBFS bar for whichever band the hop is on (level.json)
|
||||
|
||||
MEANING ░░ the AIR CURVE is the state — psi tells the story silence can't (O-10 · O-11) ░░
|
||||
└ from decoded EOT frames, per train:
|
||||
psi ~85–90 + moving = PASSING through
|
||||
psi drops to a HELD ~55–65 + motion 0 = DWELLING (brakes set — possibly blocking)
|
||||
psi bleeds to 0 AND HOLDS + head still polling = CUT & STANDING — crews cut the crossing
|
||||
(eyes-confirmed 07-05: ~11 h split, crossing open; never read the bleed as a departure)
|
||||
psi RECHARGES toward ~88 + motion 0→1 = DEPARTED (the roll-out is the event time)
|
||||
|
||||
sanity ░ NOAA 162.550 (Columbus) — the proven known-good RX test on this band
|
||||
(the per-radio toggle is parked since the Mill move; control relay TBD)
|
||||
config ░ radio→task is swappable, not hardcoded — reassign per band
|
||||
dropped ░ ATCS (900 MHz): LOS-limited + FCC-retired Sep 2025 → encrypted PTC;
|
||||
our 900 "carriers" were a dongle spur. Why → radio/sdr/atcs/tuning-log.md
|
||||
────────────────────────────────────────────────────────────────────
|
||||
why voice first: unencrypted, easy at 160 MHz, and it says a train is
|
||||
moving through your crossing in plain words — the direct commute signal.
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<!-- SYSTEM MAP — hand-maintained ASCII map of the software side: the browser, the local
|
||||
servers/services, the ports, and how data flows. Sibling to SIGNAL FLOW (which maps the air).
|
||||
A learning tool for us and whoever comes after. Keep it current as servers/flows change. -->
|
||||
<section id="stack" class="panel stack">
|
||||
<h2>System Map <span class="tag">the istrain dashboard & the services behind it</span></h2>
|
||||
<pre class="flow-chart">
|
||||
iSTRAIN — SYSTEM MAP the dashboard & the services behind it
|
||||
how the pieces talk · keep current as it changes
|
||||
legend: ─▶ request / data ◂ produced by ▣ = a container in the Dockge stack `istrain`
|
||||
updated 2026-07-18 (the Mill cutover): ONE BOX — everything below is a container on the Mill
|
||||
(a TrueNAS SCALE machine running Dockge); both dongles on its USB; every log AUTHORED where
|
||||
it's served (the VM-era push pipeline is retired); istrain.jhestyr.net serves this stack.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
|
||||
BROWSER ▸ istrain.jhestyr.net (Cloudflare ─▶ the Mill's ▣ web container)
|
||||
│
|
||||
└─▶ ▣ web serve.py — stdlib Python, no framework
|
||||
│ (keeps a jsonl PARSE CACHE keyed on file mtime/size + a 4 s TTL on the card
|
||||
│ compute — polls are ~free; the logs only change every ~20-40 s)
|
||||
├ static index.html · main.js · style.css · panels/strip.js (TWO grids: aligned week/15-min
|
||||
│ for correlate · RECENT1M = 6h/1-min zoom for the band strips)
|
||||
│ panels/{trains,live,supervisor,correlate,bot,midtrain,eot,checklist}
|
||||
├ GET /api/version ◂ dashboard/VERSION (stamped by mill/deploy.sh) ─▶ header build stamp
|
||||
├ GET /api/trains ◂ eot.jsonl + bot.jsonl (cards: tail+head ✓join, ⚓parked, ◉standing,
|
||||
│ air-curve states: passing/dwelling/CUT&standing/departed) ─▶ Trains
|
||||
├ GET /api/istrain ◂ strength-gated isTRAIN? verdict (+ dwelling-join fallback) ─▶ Banner
|
||||
├ GET /api/level ◂ level.json (live dBFS) ─▶ Live meter (2×/s)
|
||||
├ GET /api/heard ◂ heard.jsonl (voice detections) ─▶ Correlate (voice row)
|
||||
├ GET /api/eot?hours=6 ◂ eot.jsonl ─▶ EOT zoom strip (no ?hours = week ─▶ Correlate)
|
||||
├ GET /api/bot?hours=6 ◂ bot.jsonl ─▶ BOT zoom strip (ditto)
|
||||
├ GET /api/midtrain?hours=6 ◂ midtrain.jsonl ─▶ Mid-train zoom strip (±12.5k DPU + booster)
|
||||
├ GET /api/correlate ◂ scripts/correlate.py (recent-window why; booster-floor aware) ─▶ Correlate
|
||||
├ GET /api/supervisor ◂ docker state via supervisor-probe (host cron, 1/min) + DATA-FLOW
|
||||
│ liveness: hop = level.json freshness · voice = last-capture age
|
||||
└ POST /api/noaa · /api/supervisor/{ctl,reset} (parked since the move — buttons decline
|
||||
gracefully; a docker control relay is TBD)
|
||||
|
||||
DONGLES ░ USB by serial, both on the Mill through a hub · dvb driver blacklisted on the host
|
||||
├ 1001 ▸ UHF hop — 452(BOT) ⇄ 457(EOT) └ 1002 ★ ▸ rail voice 160–162 / NOAA
|
||||
|
||||
CAPTURE ░ voice + the UHF hop run at once — separate dongles, separate containers
|
||||
├ ▣ airband rtl_airband v5.2.0 (rtl-sdr-blog fork, V4-capable) -c istrain.conf
|
||||
│ ─▶ 5 NFM channels on 1002 ─▶ captures/*.mp3 ─▶ heard.jsonl
|
||||
└ ▣ scanhop rtl_tcp + iq_hop.py: retune-in-place hop on 1001 (BOT 10s ⇄ EOT 10s, monotonic dwell — step-proof)
|
||||
├ 452 ─▶ bot.jsonl + bot_clips/*.s16 (presence + clip)
|
||||
├ 457 ─▶ eot.jsonl + eot_clips/*.s16 (presence + clip)
|
||||
└ both ─▶ level.json (live meter + the hop's liveness signal)
|
||||
|
||||
DECODE ░ containers · no radio · CPU on saved clips · NEVER cull
|
||||
├ ▣ eot-decode ─▶ eot-decode.py → eot_scan (TWO-PASS: exact + adaptive/fuzzy for off-freq
|
||||
│ emitters, O-11) → unit·brake·motion (+dq provenance) → enrich eot.jsonl
|
||||
├ ▣ bot-recover ─▶ bot-recover.py: BOT clips → recover HOT frame + DECODE addressed unit → enrich bot.jsonl
|
||||
└ ▣ transcribe-worker ─▶ voice clips >5 KB ─▶ ffmpeg comms-filter ─▶ the whisper stack
|
||||
(faster-whisper small.en + VAD, a sibling Dockge stack, localhost) ─▶ sidecar .txt per clip;
|
||||
real speech only ─▶ transcripts.jsonl (durable)
|
||||
|
||||
HOUSEKEEPING ░ ▣ trim — 24 h voice-audio prune (ingest-gated; heard.jsonl KEPT)
|
||||
░ supervisor-probe — host cron, 1/min: docker truth into the snapshot
|
||||
DATA ░ born on the Mill's ZFS datasets — jsonl logs = keep forever · clips accumulate
|
||||
(keep-everything standing policy) · the VM-era corpus is archived alongside
|
||||
|
||||
build vs operate ░ the hands build the code; the radios are operated by services —
|
||||
never fired ad hoc. deploy = mill/deploy.sh: rsync ─▶ build ─▶ up ─▶ smoke, idempotent.
|
||||
history ░ the VM-capture / NAS-web era (2026-06-15 → 07-18) is preserved in MIGRATION.md;
|
||||
the VM units stay on disk, disabled — the standing rollback.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- WITH THANKS — carried from the first istrain, extended for the rebuild -->
|
||||
<section class="thanks">
|
||||
<div class="th-h">📡 WITH THANKS</div>
|
||||
<p>Railroad frequencies, the tools that pull signal out of the air, and the hands that built it.</p>
|
||||
<p class="th-grp"><b>Frequency & data communities</b><br>
|
||||
• <a href="https://trainweb.us/columbusrailfan/">Columbus Railfan Online</a> (Silver Rails / TrainWeb) — scanner frequencies<br>
|
||||
• <a href="https://www.radioreference.com/db/subcat/77377">RadioReference</a> — NS Columbus Terminal & the US Railroads index<br>
|
||||
• <a href="https://www.railroad-frequencies.com/train/norfolk-southern-railway/ohio/">Railroad-Frequencies.com</a> — NS & CSX Ohio assignments
|
||||
</p>
|
||||
<p class="th-grp"><b>Tools & projects</b><br>
|
||||
• <a href="https://www.rtl-sdr.com/">RTL-SDR Blog</a> — the Blog V4 hardware and the community behind it<br>
|
||||
• <a href="https://osmocom.org/projects/rtl-sdr/">Osmocom rtl-sdr</a> — <code>rtl_test</code> / <code>rtl_eeprom</code>, the tools that drive the dongles<br>
|
||||
• <a href="https://github.com/szpajder/RTLSDR-Airband">RTLSDR-Airband</a> — the multi-channel voice watch (we run the <a href="https://github.com/rtl-sdr-blog/RTLSDR-Airband">rtl-sdr-blog fork</a>, V4-capable)<br>
|
||||
• <a href="https://github.com/SYSTRAN/faster-whisper">faster-whisper</a> (SYSTRAN) + <a href="https://github.com/openai/whisper">OpenAI Whisper</a> — the transcription engine that lets istrain <em>hear</em> (small.en + VAD)<br>
|
||||
• <a href="https://github.com/ggerganov/whisper.cpp">whisper.cpp</a> — lit the transcription path first, in the VM era<br>
|
||||
• <a href="https://ffmpeg.org/">FFmpeg</a> — the comms-filter in front of the ears, and every clip's plumbing<br>
|
||||
• <a href="https://github.com/jvde-github/AIS-catcher">AIS-catcher</a> — AIS decode, our known-good validation signal<br>
|
||||
• <a href="https://github.com/ereuter/PyEOT">PyEOT</a> (Eric Reuter) — the open-source EOT/FFSK decoder (packet parser + BCH check) that proved we can read End-of-Train telemetry; ships the demo recording we validated against<br>
|
||||
• <a href="https://www.sigidwiki.com/wiki/End_of_Train_Device_(EOTD)">SigidWiki — EOTD</a> & <a href="https://www.rtl-sdr.com/tag/eot/">rtl-sdr.com EOT guides</a> — the 1200-baud FFSK spec and the decode trail that pointed the way<br>
|
||||
• <a href="https://numpy.org/">NumPy</a> — the signal-crunching behind the EOT demodulator<br>
|
||||
• <strong>ATCS Monitor</strong> & <a href="https://rail.watch/rwmon.html">Rail Watch Monitor</a> — the 900 MHz dispatcher's board, and the community that mapped ATCS (evaluated & dropped — kept for provenance)<br>
|
||||
• <a href="https://www.radioreference.com/db/subcat/69805">RadioReference ATCS DB</a> & <a href="https://www.sigidwiki.com/wiki/Automated_Train_Control_System_(ATCS)">SigidWiki</a> — the published channel plans that told us what we'd found<br>
|
||||
• <a href="https://www.docker.com/">Docker</a> + <a href="https://github.com/louislam/dockge">Dockge</a> (louislam) — the runtime every service lives in since the Mill move<br>
|
||||
• <a href="https://tailscale.com/">Tailscale</a> — the quiet plumbing between the boxes<br>
|
||||
• <a href="https://www.cloudflare.com/">Cloudflare</a> — the front door
|
||||
</p>
|
||||
<p class="th-grp"><b>The machine</b><br>
|
||||
• <a href="https://www.truenas.com/truenas-scale/">TrueNAS SCALE</a> on the Mill — the one box the whole rig runs on (since 2026-07-18): dongles, capture, decode, ears, and this site<br>
|
||||
• <a href="https://www.virtualbox.org/">Oracle VM VirtualBox</a> + <a href="https://linuxmint.com/">Linux Mint (LMDE)</a> — the dev seat, and the box the rig grew up inside (2026-06-15 → 07-18)
|
||||
</p>
|
||||
<p class="th-grp"><b>Foundation</b><br>
|
||||
• built on a reusable radio-capture engine and iterated with an AI pair-programmer (<a href="https://www.anthropic.com/claude">Claude</a>, Anthropic)<br>
|
||||
• a community project — the credits above are the shoulders it stands on; if your tool, frequency, or hand helped and isn’t here yet, it will be.
|
||||
</p>
|
||||
<p class="th-grp"><b>A living list</b><br>
|
||||
istrain is a community project — this list is intentionally unfinished and grows as the work brings in
|
||||
more shoulders to stand on. If your tool, frequency, or hand helped and isn't here yet, it will be.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- live: Supervisor — services + radios (moved below THANKS: the ops corner
|
||||
lives at the bottom with the errors card; the triple-tap gate is on this panel's title) -->
|
||||
<section id="supervisor" class="panel yard"></section>
|
||||
|
||||
<!-- ERRORS — live issues + recent events, below WITH THANKS (the owner 2026-07-06): the card to
|
||||
copy/share when something looks wrong. Fed by /api/errors (supervisor audit + client JS reports). -->
|
||||
<section id="errors" class="panel errors"></section>
|
||||
|
||||
<!-- BUILD CHECKLIST — rendered from checklist.md (single source; also a git host task list).
|
||||
Keep checklist.md current; this panel mirrors it live. -->
|
||||
<section id="checklist" class="panel checklist"></section>
|
||||
|
||||
<footer>
|
||||
<span id="status">live — one box: the Mill captures, decodes & presents</span> ·
|
||||
<a href="https://istrain.jhestyr.net">istrain.jhestyr.net</a> · an open community project
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
// main.js — istrain dashboard. Frame chrome (hit counter, refresh / hard-reset) + mounts live panels.
|
||||
// Panels follow the engine's panels/<name>/mount(el) contract (see the radio engine). Static placeholder
|
||||
// panels (dispatch, detect) stay in index.html until built.
|
||||
//
|
||||
// Refresh policy: slow background polls + change-aware renders so the page idles at ~0% CPU (no
|
||||
// compositor stutter). Manual "refresh" re-pulls now; "hard reset" kills captures + restarts all helpers (/api/supervisor/reset).
|
||||
|
||||
import { mountSupervisor } from '/panels/supervisor/supervisor.js';
|
||||
import { mountChecklist } from '/panels/checklist/checklist.js';
|
||||
// captures RESTORED 2026-07-10 — the new antenna arrived + whisper transcripts went live; the public
|
||||
// node gets voiced clips pushed. activity stays SHELVED (voice activity = a correlate-graph signal).
|
||||
import { mountCaptures } from '/panels/captures/captures.js';
|
||||
import { mountEot } from '/panels/eot/eot.js';
|
||||
import { mountCorrelate } from '/panels/correlate/correlate.js';
|
||||
import { mountBot } from '/panels/bot/bot.js';
|
||||
import { mountMidtrain } from '/panels/midtrain/midtrain.js';
|
||||
import { mountLive } from '/panels/live/live.js';
|
||||
import { mountTrains } from '/panels/trains/trains.js';
|
||||
import { mountBanner } from '/panels/banner/banner.js';
|
||||
import { mountErrors } from '/panels/errors/errors.js';
|
||||
|
||||
// --- client-side error reporter → the Errors card (rate-limited + escaped server/panel side) ---
|
||||
function reportErr(msg, src, line) {
|
||||
try {
|
||||
fetch('/api/errors/report', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ msg: String(msg).slice(0, 500), src: String(src || '').slice(0, 200), line: line || 0 }),
|
||||
}).catch(() => {});
|
||||
} catch { /* the reporter must never itself throw */ }
|
||||
}
|
||||
window.addEventListener('error', e => reportErr(e.message, e.filename, e.lineno));
|
||||
window.addEventListener('unhandledrejection', e => reportErr('unhandledrejection: ' + ((e.reason && e.reason.message) || e.reason), location.pathname, 0));
|
||||
|
||||
// --- hit counter (change-aware; shows "—" until the server exists) ---
|
||||
let lastHits = null;
|
||||
async function loadHits() {
|
||||
try {
|
||||
const r = await fetch('/api/hits', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const { unique, total } = await r.json();
|
||||
const key = unique + '/' + total;
|
||||
if (key === lastHits) return; // no change → no DOM write
|
||||
lastHits = key;
|
||||
if (unique != null) document.getElementById('hits-unique').textContent = unique.toLocaleString();
|
||||
if (total != null) document.getElementById('hits-total').textContent = total.toLocaleString();
|
||||
} catch { /* no server yet — leave the dashes */ }
|
||||
}
|
||||
|
||||
// --- build stamp: the commit this dashboard serves (VM: git; NAS: VERSION stamped by fetch-app.sh) ---
|
||||
async function loadVersion() {
|
||||
try {
|
||||
const r = await fetch('/api/version', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const { commit } = await r.json();
|
||||
if (commit) document.getElementById('ver').textContent = commit;
|
||||
} catch { /* leave the dash */ }
|
||||
}
|
||||
|
||||
// --- unique visitor: a persistent client token (robust behind the tunnel, where the real IP collapses) ---
|
||||
function recordVisit() {
|
||||
let vid = localStorage.getItem('istrain_vid');
|
||||
if (!vid) {
|
||||
vid = (window.crypto && crypto.randomUUID) ? crypto.randomUUID()
|
||||
: (Date.now().toString(16) + '-' + Math.floor(Math.random() * 0xffffffff).toString(16));
|
||||
localStorage.setItem('istrain_vid', vid);
|
||||
}
|
||||
fetch('/api/hit?vid=' + encodeURIComponent(vid), { cache: 'no-store' }).catch(() => {}).finally(loadHits);
|
||||
}
|
||||
|
||||
// --- live panels ---
|
||||
const supervisor = mountSupervisor(document.getElementById('supervisor'));
|
||||
const checklist = mountChecklist(document.getElementById('checklist'));
|
||||
const banner = mountBanner(document.getElementById('istrain-banner'));
|
||||
const trains = mountTrains(document.getElementById('trains'));
|
||||
const eot = mountEot(document.getElementById('eot'));
|
||||
const correlate = mountCorrelate(document.getElementById('correlate'));
|
||||
const bot = mountBot(document.getElementById('bot'));
|
||||
const midtrain = mountMidtrain(document.getElementById('midtrain'));
|
||||
const live = mountLive(document.getElementById('live'));
|
||||
const captures = mountCaptures(document.getElementById('captures'));
|
||||
const errors = mountErrors(document.getElementById('errors'));
|
||||
|
||||
// --- controls ---
|
||||
document.getElementById('btn-refresh').onclick = () => { loadHits(); banner.refresh(); supervisor.refresh(); checklist.refresh(); trains.refresh(); eot.refresh(); correlate.refresh(); bot.refresh(); midtrain.refresh(); errors.refresh(); captures.refresh(); };
|
||||
document.getElementById('btn-reset').onclick = async () => {
|
||||
if (!confirm('Hard reset — kill the captures and restart all istrain helpers? Frees the dongles; device-level DVB resets still need cmd-palette.')) return;
|
||||
const b = document.getElementById('btn-reset'), t = b.textContent;
|
||||
b.disabled = true; b.textContent = 'resetting…';
|
||||
try { await fetch('/api/supervisor/reset', { method: 'POST', headers: { 'X-Ops': '1' } }); } catch {}
|
||||
setTimeout(() => { b.disabled = false; b.textContent = t; supervisor.refresh(); eot.refresh(); correlate.refresh(); }, 3500);
|
||||
};
|
||||
|
||||
// initial pull + a slow safety poll (manual refresh drives the rest)
|
||||
recordVisit(); // beacon once per load (mints/sends the visitor token), then shows counts
|
||||
loadVersion(); // header build stamp — once per load
|
||||
setInterval(loadHits, 600000); // 10 min
|
||||
@@ -0,0 +1,127 @@
|
||||
// strip.js — shared "week of 15-minute bins" strip, used by EVERY band panel (EOT/BOT/mid-train/voice and
|
||||
// the correlation synthesis). One source of truth for binning so their columns LINE UP: bins are keyed to
|
||||
// absolute wall-clock 15-min slots (floor(ts/900)), not relative-to-now — so the same 15-min slot lands in
|
||||
// the same column on every panel. Shared canvas draw + one hover-tooltip format (date + time range, ET).
|
||||
|
||||
export const BUCKET = 900, WEEK = 7 * 86400, NB = Math.round(WEEK / BUCKET); // 672 — a week of 15-min bins
|
||||
export const TZ = 'America/New_York';
|
||||
// two grids (2026-07-05): the shared WEEK grid keeps every panel's columns aligned (correlate's job —
|
||||
// the overview); RECENT1M is the zoom the band strips use so the most recent stretch of correlate can
|
||||
// be inspected at 1-min resolution (360 bins ≈ same canvas cost as the week's 672).
|
||||
export const WEEK15 = { bucket: BUCKET, nb: NB, note: 'week · 15-min bins' };
|
||||
export const RECENT1M = { bucket: 60, nb: 360, note: 'last 6h · 1-min bins' };
|
||||
|
||||
// absolute slot alignment — rightmost bin (nb-1) is the current slot
|
||||
export function binIndex(ts, now, s = WEEK15) {
|
||||
if (!ts || ts <= 0) return -1;
|
||||
const idx = s.nb - 1 - (Math.floor(now / s.bucket) - Math.floor(ts / s.bucket));
|
||||
return (idx < 0 || idx > s.nb - 1) ? -1 : idx;
|
||||
}
|
||||
export function fillBins(events, now, getTs = e => e.ts, s = WEEK15) {
|
||||
const b = new Int16Array(s.nb);
|
||||
for (const e of events) { const i = binIndex(getTs(e), now, s); if (i >= 0) b[i]++; }
|
||||
return b;
|
||||
}
|
||||
// wall-clock window [start,end) covered by bin idx — for tooltips
|
||||
export function binTimes(idx, now, s = WEEK15) {
|
||||
const start = (Math.floor(now / s.bucket) - (s.nb - 1 - idx)) * s.bucket;
|
||||
return { start, end: start + s.bucket };
|
||||
}
|
||||
|
||||
export function drawStrip(canvas, buckets, rgb, gmaxOverride) {
|
||||
const w = Math.max(1, canvas.clientWidth | 0), h = (canvas.clientHeight | 0) || 32;
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const gmax = gmaxOverride || Math.max(1, ...buckets);
|
||||
const N = buckets.length; // works for either grid
|
||||
for (let x = 0; x < w; x++) {
|
||||
const b0 = Math.floor(x / w * N), b1 = Math.max(b0 + 1, Math.floor((x + 1) / w * N));
|
||||
let v = 0;
|
||||
for (let b = b0; b < b1 && b < N; b++) if (buckets[b] > v) v = buckets[b];
|
||||
if (!v) continue;
|
||||
const t = v / gmax, bh = Math.max(1, Math.round(t * h));
|
||||
ctx.fillStyle = `rgba(${rgb},${(0.4 + 0.6 * t).toFixed(3)})`;
|
||||
ctx.fillRect(x, h - bh, 1, bh);
|
||||
}
|
||||
}
|
||||
|
||||
const fmtStart = t => new Date(t * 1000).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: TZ });
|
||||
const fmtEnd = t => new Date(t * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: TZ });
|
||||
|
||||
// Shared hover tooltip (reuses .act-tip). resolve(ev) -> {canvas, buckets, now, unit} | null.
|
||||
// Default-to-LEFT of cursor (busy bins sit at the right edge, where a right popup gets clipped).
|
||||
export function attachTip(el, bodyEl, resolve) {
|
||||
const tip = document.createElement('div');
|
||||
tip.className = 'act-tip'; tip.style.display = 'none';
|
||||
el.appendChild(tip);
|
||||
bodyEl.addEventListener('mousemove', ev => {
|
||||
const r = resolve(ev);
|
||||
if (!r) { tip.style.display = 'none'; return; }
|
||||
const s = r.scale || WEEK15;
|
||||
const rect = r.canvas.getBoundingClientRect();
|
||||
let idx = Math.floor((ev.clientX - rect.left) / Math.max(1, rect.width) * s.nb);
|
||||
idx = Math.max(0, Math.min(s.nb - 1, idx));
|
||||
const { start, end } = binTimes(idx, r.now, s);
|
||||
const label = r.label ? r.label(idx) : `${r.buckets[idx]} ${r.unit || 'tx'}`;
|
||||
tip.textContent = `${label} · ${fmtStart(start)}–${fmtEnd(end)} ET`;
|
||||
tip.style.display = 'block';
|
||||
const gap = 12;
|
||||
let lx = ev.clientX - gap - tip.offsetWidth;
|
||||
if (lx < 4) lx = ev.clientX + gap;
|
||||
tip.style.left = lx + 'px';
|
||||
tip.style.top = Math.max(4, ev.clientY - 30) + 'px';
|
||||
});
|
||||
bodyEl.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
|
||||
}
|
||||
|
||||
// Factory for a single-band PRESENCE panel (EOT/BOT/mid-train). opts:
|
||||
// {id, title, tag, freqTag, rgb, endpoint, nearSec, nearLabel, emptyHtml, scale?}
|
||||
// scale defaults to the shared week grid; pass RECENT1M for the 1-min zoom (endpoint should then
|
||||
// carry ?hours=6 so the server only ships the window being drawn).
|
||||
export function mountBandStrip(el, o) {
|
||||
const scale = o.scale || WEEK15;
|
||||
el.innerHTML =
|
||||
`<h2>${o.title} <span class="tag" id="${o.id}-tag">${o.tag}</span></h2><div id="${o.id}-body"></div>`;
|
||||
const tagEl = el.querySelector(`#${o.id}-tag`);
|
||||
const bodyEl = el.querySelector(`#${o.id}-body`);
|
||||
let lastKey = null, lastBuckets = null, lastNow = 0;
|
||||
const fmtT = e => new Date(e * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: TZ });
|
||||
|
||||
const paint = () => {
|
||||
const cv = bodyEl.querySelector('canvas.eot-strip');
|
||||
if (cv && lastBuckets) drawStrip(cv, lastBuckets, o.rgb);
|
||||
};
|
||||
attachTip(el, bodyEl, ev => {
|
||||
const cv = ev.target.closest && ev.target.closest('canvas.eot-strip');
|
||||
return (cv && lastBuckets) ? { canvas: cv, buckets: lastBuckets, now: lastNow, unit: 'bursts', scale } : null;
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
let d = { bursts: [], total: 0 };
|
||||
try { const r = await fetch(o.endpoint, { cache: 'no-store' }); if (r.ok) d = await r.json(); }
|
||||
catch { return; }
|
||||
const bursts = d.bursts || [], now = Date.now() / 1000, w = bodyEl.clientWidth | 0;
|
||||
const newest = bursts.length ? bursts[0].ts : 0;
|
||||
const key = (d.total || 0) + '|' + newest + '|' + w + '|' + Math.floor(now / scale.bucket);
|
||||
if (key === lastKey) return;
|
||||
lastKey = key; lastNow = now;
|
||||
lastBuckets = fillBins(bursts, now, e => e.ts, scale);
|
||||
tagEl.textContent = d.total ? `${o.freqTag} · ${d.total} bursts · ${scale.note}` : o.tag;
|
||||
if (!bursts.length) { bodyEl.innerHTML = `<div class="placeholder">${o.emptyHtml}</div>`; return; }
|
||||
const last = newest, hr = bursts.filter(b => now - b.ts <= 3600).length, near = last && (now - last <= o.nearSec);
|
||||
bodyEl.innerHTML =
|
||||
`<div class="eot-head">` +
|
||||
`<span class="eot-state"${near ? ` style="color:rgb(${o.rgb})"` : ''}>${near ? o.nearLabel : 'quiet'}</span>` +
|
||||
`<span class="eot-last">last ${fmtT(last)} ET</span>` +
|
||||
`<span class="eot-n">${hr}× last hr</span>` +
|
||||
`</div><canvas class="eot-strip"></canvas>`;
|
||||
requestAnimationFrame(paint);
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
let rt;
|
||||
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { lastKey = null; refresh(); }, 250); });
|
||||
return { refresh };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"generated": "2026-06-18T15:36:59+00:00",
|
||||
"radios": [
|
||||
{
|
||||
"serial": "1001",
|
||||
"product": "RTL2838UHIDIR",
|
||||
"manufacturer": "Realtek",
|
||||
"role": "EOT/BOT \u00b7 452/457",
|
||||
"driver": null,
|
||||
"blocked_by_dvb": false,
|
||||
"present": true
|
||||
},
|
||||
{
|
||||
"serial": "1002",
|
||||
"product": "Blog V4",
|
||||
"manufacturer": "RTLSDRBlog",
|
||||
"role": "rail voice \u00b7 160",
|
||||
"driver": null,
|
||||
"blocked_by_dvb": false,
|
||||
"present": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
/* istrain dashboard — frame. Color scheme carried over from the radio/marine dashboard
|
||||
(the reusable radio engine). Signature look: each panel wears a colored left stripe. */
|
||||
:root{
|
||||
--bg:#0b1620; --panel:#13212e; --ink:#cfe3f0; --muted:#7d97a8;
|
||||
--line:#243a4d; --accent:#37b3a4; --good:#2c9c6b; --bad:#e0413f;
|
||||
/* per-panel left-stripe colors */
|
||||
--c-dispatch:#e0b341; --c-detect:#37b3a4; --c-yard:#7fb3ff; --c-future:#5a7088;
|
||||
--c-flow:#b48ce0; --c-stack:#57b6c9;
|
||||
--c-captures:#e0884d; --c-activity:#b6c94d; --c-detectors:#e06b8a; --c-feeds:#8c9ee0;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)}
|
||||
|
||||
header{position:sticky;top:0;background:#0a131c;border-bottom:1px solid var(--line);padding:12px 16px;z-index:10;
|
||||
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
header h1{margin:0;font-size:20px;font-weight:700;letter-spacing:.02em}
|
||||
header h1 .sub{font-size:12px;font-weight:400;color:var(--muted);margin-left:10px}
|
||||
|
||||
/* hits counter — header stat block */
|
||||
.hdr-right{display:flex;align-items:center;gap:16px}
|
||||
.controls{display:flex;gap:8px}
|
||||
.btn{border:1px solid var(--line);background:#1b2c3b;color:var(--ink);padding:7px 12px;border-radius:6px;font:600 12px/1 system-ui;cursor:pointer}
|
||||
.btn:hover{filter:brightness(1.18)}
|
||||
.btn:active{transform:translateY(1px)}
|
||||
.btn.reset{background:#3a1414;border-color:#7a2a28;color:#ff9a98}
|
||||
.hits{display:flex;gap:18px;align-items:center}
|
||||
.hits .stat{text-align:right}
|
||||
.hits .num{font:700 20px/1 ui-monospace,monospace;color:var(--accent);font-variant-numeric:tabular-nums}
|
||||
.hits .lab{font-size:10px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);margin-top:3px}
|
||||
|
||||
main{display:grid;gap:14px;padding:16px;max-width:1000px;margin:0 auto}
|
||||
.panel{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--c-future);
|
||||
border-radius:8px;padding:14px;min-width:0} /* min-width:0 lets a grid item shrink so a wide <pre> scrolls inside it instead of widening the whole page (mobile) */
|
||||
.panel h2{margin:0 0 10px;font-size:14px;color:var(--accent);letter-spacing:.04em;text-transform:uppercase}
|
||||
.panel h2 .tag{font-size:11px;color:var(--muted);font-weight:400;letter-spacing:.02em;text-transform:none;margin-left:8px}
|
||||
|
||||
/* left-stripe colors per panel role */
|
||||
.panel.dispatch{border-left-color:var(--c-dispatch)}
|
||||
.panel.dispatch h2{color:var(--c-dispatch)}
|
||||
.panel.detect{border-left-color:var(--c-detect)}
|
||||
.panel.yard{border-left-color:var(--c-yard)}
|
||||
.panel.yard h2{color:var(--c-yard)}
|
||||
.panel.flow{border-left-color:var(--c-flow)}
|
||||
.panel.flow h2{color:var(--c-flow)}
|
||||
.panel.stack{border-left-color:var(--c-stack)}
|
||||
.panel.stack h2{color:var(--c-stack)}
|
||||
/* per-PANEL distinct colors — id overrides the reused role-class so each panel reads as its own thing */
|
||||
.panel#captures{border-left-color:var(--c-captures)} .panel#captures h2{color:var(--c-captures)}
|
||||
.panel#activity{border-left-color:var(--c-activity)} .panel#activity h2{color:var(--c-activity)}
|
||||
.panel#detectors{border-left-color:var(--c-detectors)} .panel#detectors h2{color:var(--c-detectors)}
|
||||
.panel#feeds{border-left-color:var(--c-feeds)} .panel#feeds h2{color:var(--c-feeds)}
|
||||
|
||||
/* signal-flow ASCII map — hand-maintained <pre>; scrolls sideways if it can't fit */
|
||||
.flow-chart{margin:0;overflow-x:auto;-webkit-overflow-scrolling:touch;
|
||||
background:#0a131c;border:1px solid var(--line);border-radius:6px;padding:14px;
|
||||
color:#c4dcea;font:11px/1.42 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
|
||||
.placeholder{color:var(--muted);background:#0a131c;border:1px dashed var(--line);border-radius:6px;
|
||||
padding:22px;text-align:center;line-height:1.7;font-size:13px}
|
||||
.placeholder b{color:var(--ink)}
|
||||
|
||||
/* radios panel — per-dongle liveness table + status dots */
|
||||
table.radios{width:100%;border-collapse:collapse;font-size:13px}
|
||||
table.radios td{padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
|
||||
table.radios tr:last-child td{border-bottom:none}
|
||||
table.radios .sn{font-family:ui-monospace,monospace;color:var(--accent);font-weight:700;width:64px}
|
||||
table.radios .role{color:var(--ink);text-transform:capitalize;width:80px}
|
||||
table.radios .nm{color:var(--muted);font-size:12px}
|
||||
table.radios .st{font-size:12px;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle}
|
||||
.dot.ready{background:#6f9bd9;box-shadow:0 0 7px #6f9bd9}
|
||||
.dot.listening{background:var(--good);box-shadow:0 0 7px var(--good)}
|
||||
.dot.blocked{background:#e0b341;box-shadow:0 0 7px #e0b341}
|
||||
.dot.offline{background:var(--bad);box-shadow:0 0 7px var(--bad)}
|
||||
/* the missing middle states (06-25 truth-by-data-flow): held-but-no-samples is the dangerous lie */
|
||||
.dot.stalled{background:#e06a2e;box-shadow:0 0 8px #e06a2e}
|
||||
.dot.held{background:#8a93a6;box-shadow:0 0 6px #8a93a6}
|
||||
.st.ready{color:#6f9bd9} .st.listening{color:var(--good)} .st.blocked{color:#e0b341} .st.offline{color:var(--bad)}
|
||||
.st.stalled{color:#e06a2e} .st.held{color:#8a93a6}
|
||||
.sv-detail.stalled{color:#e06a2e;opacity:1;font-weight:600}
|
||||
|
||||
/* errors card — live issues + recent events (below WITH THANKS) */
|
||||
.err-row{font-family:ui-monospace,monospace;font-size:12px;padding:4px 6px;border-bottom:1px solid var(--line);line-height:1.45}
|
||||
.err-row:last-child{border-bottom:none}
|
||||
.err-row.err-live{color:#e06a2e;font-weight:600}
|
||||
.err-row.err-err{color:#d9705e}
|
||||
.err-row.err-event{color:var(--muted)}
|
||||
/* grid panel order (the owner's layout, 2026-06-23; supervisor moved OUT of <main> to the ops corner
|
||||
below THANKS 2026-07-06): correlate → bot → mid-train → eot → voice activity → captures →
|
||||
detectors → feeds → signal flow → system map. The signal panels run FRONT-to-BACK of a train
|
||||
(bot=head · mid=DPU · eot=rear). Thanks + supervisor + errors + checklist follow after <main>. */
|
||||
#correlate{order:-1} /* overview panel — pinned to the top, under the banner, before the train cards */
|
||||
#live{order:0} #bot{order:3} #midtrain{order:4} #eot{order:5} #activity{order:6}
|
||||
#captures{order:7} #detectors{order:8} #feeds{order:9} #flow{order:10} #stack{order:11}
|
||||
#dispatch{order:12} /* Dispatch/Yard whisper placeholder — parked at the bottom; may use it later */
|
||||
table.supervisor{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
table.supervisor td{padding:4px 6px;border-bottom:1px solid rgba(255,255,255,.05);vertical-align:middle}
|
||||
table.supervisor tr:last-child td{border-bottom:0}
|
||||
.sv-grp td{font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);padding:8px 6px 2px;border-bottom:0}
|
||||
.sv-name{font-weight:600}
|
||||
.sv-detail{font-size:11.5px;opacity:.85}
|
||||
.sv-since{font-size:11px;color:var(--muted);white-space:nowrap;text-align:right}
|
||||
.sv-actions{text-align:right;white-space:nowrap}
|
||||
.sv-ctl{background:none;border:1px solid rgba(255,255,255,.2);color:inherit;opacity:.55;cursor:pointer;font-size:11px;line-height:1;padding:2px 6px;border-radius:3px;margin-left:3px}
|
||||
.sv-ctl:hover{opacity:1;border-color:var(--accent);color:var(--accent)}
|
||||
|
||||
/* NOAA per-radio sanity toggle — idle = quiet outline; on = live green (audio playing, hijacked) */
|
||||
.noaa-btn{border:1px solid var(--line);background:#1b2c3b;color:var(--ink);
|
||||
border-radius:6px;font:600 11px/1 system-ui;text-transform:none;letter-spacing:0;
|
||||
cursor:pointer;white-space:nowrap}
|
||||
.noaa-btn:hover{filter:brightness(1.18)}
|
||||
.noaa-btn:active{transform:translateY(1px)}
|
||||
.noaa-btn:disabled{opacity:.55;cursor:wait}
|
||||
.noaa-btn.on{background:var(--good);border-color:var(--good);color:#eafff4;box-shadow:0 0 8px var(--good)}
|
||||
.noaa-btn.mini{padding:4px 9px}
|
||||
table.radios .noaa-cell{text-align:right;width:1%;white-space:nowrap}
|
||||
.noaa-na{color:var(--muted);opacity:.5}
|
||||
|
||||
/* build checklist — rendered from checklist.md; below thanks. centered like thanks since it
|
||||
sits outside <main>. checked items dim + strike; group headers rule off the sections. */
|
||||
.panel.checklist{border-left-color:var(--good)}
|
||||
/* every section OUTSIDE <main> (thanks / supervisor / errors / checklist) gets the SAME effective
|
||||
width as the in-main panels (main = 1000px cap minus 16px side padding → 968px interior), so
|
||||
the whole page reads one column — no full-window-wrapping strays. */
|
||||
.thanks, .panel.checklist, #supervisor, #errors{
|
||||
max-width:968px;margin:14px auto;width:calc(100% - 32px);box-sizing:border-box}
|
||||
.panel.checklist h2{color:var(--good)}
|
||||
.cl-intro{color:var(--muted);font-size:12px;line-height:1.5;margin-bottom:6px}
|
||||
.cl-group{color:var(--ink);font-weight:700;font-size:12px;letter-spacing:.04em;text-transform:uppercase;
|
||||
margin:12px 0 6px;padding-bottom:3px;border-bottom:1px solid var(--line)}
|
||||
.cl-item{display:flex;gap:8px;align-items:flex-start;font-size:13px;padding:2px 0;color:var(--ink)}
|
||||
.cl-item.cl-sub{margin-left:22px;font-size:12px;color:var(--muted)}
|
||||
.cl-item.done{color:var(--muted)}
|
||||
.cl-item.done .cl-text{text-decoration:line-through;opacity:.7}
|
||||
.cl-box{flex-shrink:0;color:var(--muted);line-height:1.5}
|
||||
.cl-item.done .cl-box{color:var(--good)}
|
||||
.cl-text code,.cl-intro code{background:#0a131c;border:1px solid var(--line);border-radius:3px;
|
||||
padding:0 4px;font-size:11px;color:var(--accent);word-break:break-word}
|
||||
|
||||
/* with thanks — carried from the first istrain, extended for the rebuild */
|
||||
.thanks{padding:14px 16px;background:#0f1a24;border:1px solid var(--line);
|
||||
border-left:4px solid var(--good);border-radius:8px;font:12px/1.65 ui-monospace,monospace;color:var(--muted)}
|
||||
/* width/margin come from the shared outside-main rule above */
|
||||
.thanks .th-h{color:var(--good);letter-spacing:.12em;font-weight:700;margin-bottom:8px}
|
||||
.thanks p{margin:8px 0}
|
||||
.thanks .th-grp b{color:var(--ink);display:inline-block;margin-bottom:2px}
|
||||
.thanks a{color:#7ec8e3;text-decoration:none}
|
||||
.thanks a:hover{text-decoration:underline}
|
||||
.thanks strong{color:var(--ink)}
|
||||
.thanks em{color:var(--ink);font-style:italic}
|
||||
.thanks .dim{color:#5a7088}
|
||||
|
||||
footer{color:var(--muted);text-align:center;padding:16px;font-size:12px}
|
||||
footer a{color:var(--accent);text-decoration:none}
|
||||
|
||||
/* ---- narrow / portrait (phone) ---- istrain will mostly be viewed on mobile.
|
||||
Stack the header instead of fighting for one row, trim padding, and let the
|
||||
radios table wrap instead of overflowing a ~360px screen. */
|
||||
@media (max-width:560px){
|
||||
body{font:13px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||
/* header: title on its own line, sub beneath it, controls+hits on a full-width row */
|
||||
header{padding:10px 12px;gap:10px}
|
||||
header h1{font-size:17px;display:flex;flex-direction:column;gap:2px}
|
||||
header h1 .sub{margin-left:0;font-size:11px}
|
||||
.hdr-right{width:100%;justify-content:space-between;gap:10px}
|
||||
.hits{gap:14px}
|
||||
.hits .num{font-size:17px}
|
||||
.btn{padding:7px 10px}
|
||||
/* body: tighter gutters so panels get the width back */
|
||||
main{padding:12px;gap:12px}
|
||||
.panel{padding:12px}
|
||||
.placeholder{padding:16px 12px;font-size:12.5px;line-height:1.6}
|
||||
/* radios table: drop fixed cell widths, allow long product names to wrap */
|
||||
table.radios{font-size:12.5px}
|
||||
table.radios td{padding:6px 6px}
|
||||
table.radios .sn,table.radios .role{width:auto}
|
||||
table.radios .nm{word-break:break-word}
|
||||
.thanks{padding:12px;font-size:11.5px}
|
||||
.flow-chart{font-size:10px;padding:12px 10px}
|
||||
footer{padding:14px 12px}
|
||||
}
|
||||
|
||||
/* captures panel — rail-voice clip list (light: native <audio> preload=none, change-aware renders) */
|
||||
.cap-row{display:flex;flex-direction:column;gap:4px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}
|
||||
.cap-row:last-child{border-bottom:0}
|
||||
.cap-meta{display:flex;justify-content:space-between;align-items:baseline;gap:10px;font-size:12.5px}
|
||||
.cap-ch{font-weight:600}
|
||||
.cap-time{opacity:.6;font-size:11px;white-space:nowrap}
|
||||
.cap-audio{width:100%;height:30px}
|
||||
.cap-text{font-size:12px;opacity:.85;font-style:italic;padding-left:2px}
|
||||
.cap-right{display:flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
.cap-del{background:none;border:1px solid rgba(255,255,255,.18);color:inherit;opacity:.4;cursor:pointer;font-size:11px;line-height:1;padding:2px 6px;border-radius:3px}
|
||||
.cap-del:hover{opacity:1;color:#e06b5c;border-color:#e06b5c}
|
||||
.cap-play{background:none;border:1px solid rgba(255,255,255,.2);color:inherit;opacity:.6;cursor:pointer;font-size:10px;line-height:1;padding:2px 7px;border-radius:3px}
|
||||
.cap-play:hover{opacity:1;color:var(--accent);border-color:var(--accent)}
|
||||
.cap-play.on{opacity:.3;cursor:default}
|
||||
/* activity panel — per-channel week strips (15-min buckets, canvas) */
|
||||
.act-item{margin:0 0 9px}
|
||||
.act-item:last-child{margin-bottom:0}
|
||||
.act-head{display:flex;align-items:baseline;gap:8px;font-size:12.5px;line-height:1.4}
|
||||
.act-head .act-ch{flex:1;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.act-head .act-n{font-variant-numeric:tabular-nums;opacity:.85}
|
||||
.act-head .act-last{opacity:.55;font-size:11px;white-space:nowrap}
|
||||
.act-strip{display:block;width:100%;height:32px;margin-top:3px;background:rgba(255,255,255,.045);border-radius:2px;cursor:crosshair}
|
||||
.act-mission .act-ch{color:#37b3a4}
|
||||
.act-tip{position:fixed;z-index:60;pointer-events:none;background:rgba(18,22,26,.97);border:1px solid rgba(255,255,255,.15);border-radius:3px;padding:3px 7px;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;color:#e8eef0;box-shadow:0 2px 10px rgba(0,0,0,.45)}
|
||||
|
||||
/* EOT panel — presence on 457.9375 (dongle 1001) */
|
||||
.panel#eot{border-left-color:#e6aa46} .panel#eot h2{color:#e6aa46}
|
||||
.panel#bot{border-left-color:#b482e6} .panel#bot h2{color:#b482e6} /* violet — head/front */
|
||||
.panel#midtrain{border-left-color:#5ab4d2} .panel#midtrain h2{color:#5ab4d2} /* blue — mid-consist/DPU */
|
||||
.eot-head{display:flex;align-items:baseline;gap:10px;font-size:12.5px;margin-bottom:2px}
|
||||
.eot-state{font-weight:600;color:var(--muted)}
|
||||
.eot-state.eot-near{color:#e6aa46}
|
||||
.eot-last{color:var(--muted);font-size:11px}
|
||||
.eot-n{margin-left:auto;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}
|
||||
.eot-strip{display:block;width:100%;height:32px;margin-top:3px;background:rgba(255,255,255,.045);border-radius:2px}
|
||||
/* correlate panel — multi-band train-activity synthesis (teal = the mission/detection panel) */
|
||||
.panel#correlate{border-left-color:#37b3a4} .panel#correlate h2{color:#37b3a4}
|
||||
.corr-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12.5px;margin-bottom:4px}
|
||||
.corr-state{font-weight:700;letter-spacing:.02em;padding:1px 7px;border-radius:4px;color:var(--muted)}
|
||||
.corr-state.corr-single{color:#e6aa46} /* amber — one band, unconfirmed */
|
||||
.corr-state.corr-candidate{color:#08121a;background:#37b3a4} /* teal solid — bands co-firing */
|
||||
.corr-state.corr-error{color:#e06b6b}
|
||||
.corr-key{display:inline-flex;align-items:center;gap:5px;color:var(--muted);font-size:11.5px}
|
||||
.corr-key::before{content:'';width:8px;height:8px;border-radius:50%;background:rgb(var(--c))}
|
||||
.corr-key b{color:var(--ink);font-weight:600}
|
||||
.corr-strip{display:block;width:100%;height:92px;margin-top:2px;background:rgba(255,255,255,.045);border-radius:3px} /* 4-row heatmap */
|
||||
.corr-why{margin-top:5px;font-size:11px;line-height:1.55;color:var(--muted);font-family:ui-monospace,Menlo,Consolas,monospace}
|
||||
.corr-why-line{white-space:pre-wrap}
|
||||
/* live signal meter — compact bar, top of the grid */
|
||||
.panel#live{padding:8px 12px;border-left-color:#6b7785}
|
||||
.live-row{display:flex;align-items:center;gap:10px}
|
||||
.live-label{font-weight:600;font-size:12px;min-width:118px;color:var(--muted)}
|
||||
.live-track{position:relative;flex:1;height:14px;background:rgba(255,255,255,.06);border-radius:7px;overflow:hidden}
|
||||
.live-fill{height:100%;width:0;background:#888;border-radius:7px;transition:width .12s linear,background .2s}
|
||||
.live-floor{position:absolute;top:0;bottom:0;width:2px;background:rgba(255,255,255,.55);transition:left .3s;pointer-events:none} /* adaptive noise floor */
|
||||
.live-db{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:74px;text-align:right}
|
||||
.panel#live.live-on{border-left-color:#fff}
|
||||
/* (detections panel removed 2026-06-19 — Activity absorbed it; heard.jsonl + /api/heard stay) */
|
||||
Reference in New Issue
Block a user