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
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# hot_explore.py — Head-of-Train (HOT, 452.9375) reverse-engineering explorer. STANDALONE / offline.
#
# STATUS (2026-06-26): structure cracked, fields NOT decoded. There is no public HOT bit-map (see
# ../../EOT-HOT-PROTOCOL.md) — this is pure RE off our own bot_clips/*.s16. What works so far:
# - reuse the proven EOT FFSK front-end (eot_scan: 1200-baud, mark 1200 / space 1800 Hz)
# - a strong HOT burst shows a ~440-bit alternating PREAMBLE (spec: 456) then a 64-bit REPEAT PERIOD
# (spec: a 64-bit block sent 3x). Majority-voting the 3 internal copies -> a CLEAN 64-bit block.
# - artifact test: different sessions give DIFFERENT clean blocks (real frames, not a stuck carrier).
# NEXT (open): pin the 24-bit frame-sync word, locate the 17-bit addressed-EOT-ID in the 30-bit payload
# (validate against a known EOT unit heard concurrently), recover the HOT BCH(33) polynomial.
#
# Usage: python3 hot_explore.py ~/istrain/bot_clips/bot_*.s16 (defaults to a strong-clip sample)
import sys, os, glob, numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # eot_scan lives next door
import eot_scan as E
def best_bits(path):
"""Demod FFSK to raw bits; pick the tone pair giving the longest alternating preamble (= bit-sync)."""
x, fr = E.load(path); W = max(8, int(round(fr / E.BAUD)))
best = None
for flo, fhi in E.TONE_PAIRS:
bs = E.bitstring(E.discriminator(x, fr, flo, fhi, W), fr)
run = mx = 0
for i in range(1, len(bs)):
if bs[i] != bs[i-1]: run += 1; mx = max(mx, run)
else: run = 0
if best is None or mx > best[0]: best = (mx, bs)
return best
def preamble_end(bs):
run = mx = end = 0
for i in range(1, len(bs)):
if bs[i] != bs[i-1]:
run += 1
if run > mx: mx = run; end = i + 1
else: run = 0
return end
def vote(bits):
"""Find (period, offset) maximizing 3-way agreement among 3 consecutive blocks, then majority-vote."""
a = np.array([int(c) for c in bits]); best = None
for p in range(60, 69):
for off in range(0, min(len(a) - 3*p, 30)) if len(a) >= 3*p else []:
b1, b2, b3 = a[off:off+p], a[off+p:off+2*p], a[off+2*p:off+3*p]
agree = np.sum(b1 == b2) + np.sum(b1 == b3) + np.sum(b2 == b3)
if best is None or agree > best[0]: best = (agree, p, off, (b1, b2, b3))
if best is None: return None
agree, p, off, (b1, b2, b3) = best
cons = ((b1.astype(int) + b2 + b3) >= 2).astype(int)
errs = [int(np.sum(b != cons)) for b in (b1, b2, b3)]
return p, off, ''.join(map(str, cons.tolist())), errs, agree, 3 * p
def explore(path):
mx, bs = best_bits(path)
body = bs[preamble_end(bs):]
if len(body) < 3 * 60:
print(f"{os.path.basename(path):30s} preamble={mx:3d}b (body too short: {len(body)}b — likely not a frame)")
return None
r = vote(body)
if not r: return None
p, off, cons, errs, agree, maxa = r
print(f"{os.path.basename(path):30s} preamble={mx:3d}b period={p} off={off} "
f"3way={agree}/{maxa}={agree/maxa:.0%} vote-err={errs} block={cons}")
return cons
if __name__ == '__main__':
paths = sys.argv[1:] or sorted(glob.glob(os.path.expanduser("~/istrain/bot_clips/bot_20260625_225*.s16")))[:4]
blocks = [b for b in (explore(p) for p in paths) if b]
if len(blocks) > 1:
print("\nartifact test — distinct consensus blocks (same everywhere = artifact; varying = real):",
len(set(blocks)))