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
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
# eot-decode.py — DECODE-ONLY service for the EOT band. Watches ~/istrain/eot_clips for finished clips,
# decodes each (clip -> unit/telemetry via eot_scan) and ENRICHES the matching record in eot.jsonl.
#
# It does NOT cull. No clip is ever deleted or moved — the BOT reverse-engineering and the "more examples
# to train on" both need every clip kept. This is the half of the old reaper that mattered, split out:
# the reaper bundled decode WITH culling, so pausing the cull (which we want off) also paused identification.
# Decode now stands alone; culling can stay off until storage actually forces it.
#
# Marking done WITHOUT moving/deleting: a tiny processed-set at eot_clips/.decoded.json (each clip decoded
# once). Safe log write: read + enrich matched records, recapture any tail the capture appended since the
# read, then atomic temp-rename — the capture only ever appends, so we never lose a presence line.
#
# Operated as the istrain-eot-decode --user service (on/off + hard-reset via the dashboard supervisor).
# Touches NO radio — pure CPU on saved clips — so it is safe to start/stop/restart at any time.
# CLI: eot-decode.py [--once]
import os, sys, glob, time, json
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from reaper import _eot_decode, _clip_epoch # reuse the PROVEN decode adapter + clip->epoch
CLIPS = os.path.expanduser(os.environ.get("EOT_CLIPS", "~/istrain/eot_clips"))
LOG = os.path.expanduser(os.environ.get("EOT_LOG", "~/istrain/eot.jsonl"))
STATE = os.path.join(CLIPS, ".decoded.json")
MIN_AGE = int(os.environ.get("DECODE_MIN_AGE", "60")) # never touch a clip <60s old (maybe still mid-write)
INTERVAL = int(os.environ.get("DECODE_INTERVAL", "30")) # seconds between passes
def _load_state():
try: return set(json.load(open(STATE)))
except Exception: return set()
def _save_state(s):
tmp = STATE + ".tmp"
with open(tmp, "w") as f: json.dump(sorted(s), f)
os.replace(tmp, STATE)
def _enrich(decodes):
"""Merge {sec: decoded-fields} into eot.jsonl by integer-ts match — atomically, without losing any
line the capture appended while we worked (re-read past the byte offset we started at, then rename)."""
if not decodes or not os.path.exists(LOG):
return
size = os.path.getsize(LOG)
out = []
def _take(fh):
for line in fh:
line = line.strip()
if not line: continue
try: r = json.loads(line)
except Exception: continue
s = int(r.get("ts", 0))
if s in decodes: r.update(decodes[s])
out.append(r)
with open(LOG) as f:
_take(f)
try: # recapture appends since our read (capture only appends)
with open(LOG) as f:
f.seek(size); _take(f)
except Exception:
pass
tmp = LOG + ".tmp"
with open(tmp, "w") as f:
for r in out: f.write(json.dumps(r) + "\n")
os.replace(tmp, LOG)
def one_pass():
"""Decode every not-yet-processed, settled clip; enrich the log; mark them done. Returns (tried, ok)."""
if not os.path.isdir(CLIPS):
return (0, 0)
state = _load_state()
now = time.time()
decodes = {}; tried = ok = 0; newly = []
for c in sorted(glob.glob(os.path.join(CLIPS, "eot_*.s16"))): # top-level only — confirmed/ is left alone
fn = os.path.basename(c)
if fn in state:
continue
if now - os.path.getmtime(c) < MIN_AGE: # too fresh — try it next pass, don't mark yet
continue
try:
fields = _eot_decode(c)
except Exception as e:
sys.stderr.write("[eot-decode] err %s: %s\n" % (fn, e)); continue
tried += 1; newly.append(fn)
if fields:
ok += 1
sec = _clip_epoch(c, "eot_")
if sec is not None: decodes[sec] = fields
sys.stderr.write("[eot-decode] ✅ %s unit=%s pressure=%s motion=%s\n"
% (fn, fields.get("unit"), fields.get("pressure"), fields.get("motion")))
if decodes:
_enrich(decodes)
if newly:
state.update(newly); _save_state(state)
return (tried, ok)
def main():
if "--once" in sys.argv[1:]:
t, o = one_pass(); print("[eot-decode] one pass: tried %d, decoded %d" % (t, o)); return
sys.stderr.write("[eot-decode] decode-only watcher up — %s every %ds (no cull, no move, no delete)\n"
% (CLIPS, INTERVAL)); sys.stderr.flush()
while True:
try:
t, o = one_pass()
if t: sys.stderr.write("[eot-decode] pass: tried %d, decoded %d\n" % (t, o)); sys.stderr.flush()
except Exception as e:
sys.stderr.write("[eot-decode] pass err: %s\n" % e); sys.stderr.flush()
time.sleep(INTERVAL)
if __name__ == "__main__":
main()