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:
istrain
2026-07-24 23:19:16 -04:00
commit 559caead36
41 changed files with 6740 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""istrain — Mill supervisor probe. Runs on the TrueNAS HOST via cron (every minute, root):
docker state -> data/supervisor.json in the dashboard's snapshot shape, so the supervisor card
tells Docker-era truth (it snapshotted the VM's systemd units until 2026-07-18). Read-only:
can_ctl=false on every row — site control buttons are parked until a docker control relay exists
(MILL-MIGRATION.md open item). Atomic write; stdlib only (host python3, no pip)."""
import json, os, subprocess, tempfile, time
STACK = "/mnt/.ix-apps/app_mounts/dockge/stacks/istrain"
OUT = os.path.join(STACK, "data", "supervisor.json")
# (container, name, job) — mirrors the VM card's rows; names kept identical where the role carried over.
ROLES = [
("istrain-web", "Dashboard", "serves this dashboard + data API (Mill :3456, the Cloudflare origin)"),
("istrain-scanhop", "UHF · 1001", "dongle 1001: 452 ⇄ 457 retune-in-place hop → BOT/EOT/MID logs + clips + live meter"),
("istrain-airband", "Voice · 1002", "dongle 1002: rail voice, 5 NS channels → mp3 clips + heard log"),
("istrain-worker", "Transcribe", "voice clips → whisper on this box (:9000) → sidecars + transcripts log"),
("istrain-eot-decode", "EOT decode", "eot_clips → unit/telemetry; enriches eot.jsonl (never culls)"),
("istrain-bot-recover", "BOT recover", "bot_clips → clean head-end frame; enriches bot.jsonl (never culls)"),
("istrain-trim", "Trim", "hourly voice-clip prune (>24h) — heard.jsonl ingest-gated, detections kept"),
("istrain-whisper", "Whisper ASR", "faster-whisper small.en (istrain-whisper stack, :9000)"),
]
DOTS = {"running": ("active", "listening"), "restarting": ("failed", "stalled"),
"paused": ("inactive", "held"), "exited": ("inactive", "offline")}
def main():
out = subprocess.run(["docker", "ps", "-a", "--format", "{{.Names}}\t{{.State}}\t{{.Status}}"],
capture_output=True, text=True).stdout
have = {}
for ln in out.splitlines():
p = ln.split("\t")
if len(p) >= 3:
have[p[0]] = (p[1], p[2])
units = []
for cid, name, job in ROLES:
state, status = have.get(cid, ("absent", "no container"))
st, dot = DOTS.get(state, ("inactive", "offline"))
units.append({"id": cid, "name": name, "kind": "service", "job": job,
"dot": dot, "state": st, "detail": status, "since": "", "can_ctl": False})
snap = {"units": units, "ts": time.time(), "controls": False}
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(OUT))
with os.fdopen(fd, "w") as f:
json.dump(snap, f)
os.replace(tmp, OUT)
if __name__ == "__main__":
main()