Files
istrain 559caead36 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>
2026-07-24 23:19:16 -04:00

139 lines
5.7 KiB
Python

#!/usr/bin/env python3
# bot-recover.py — capture-what-we-can for the BOT/HOT (452.9375) head-end band. We can't *decode* HOT
# yet (the field map / sync word / BCH are unpublished — see ../../EOT-HOT-PROTOCOL.md), but we CAN
# RECOVER the clean 64-bit frame: majority-vote the 3x-repeated block out of a strong BOT clip. This step
# does that for each settled BOT clip and ENRICHES the matching bot.jsonl record with the recovered block
# (undecoded), so the card's head section can show "head-end frame captured" — and the decode just drops
# into the same record later.
#
# Mirrors eot-decode.py exactly: decode-only mindset, NEVER culls (no clip moved or deleted — every BOT
# clip is kept for the ongoing RE), a processed-set so each clip is done once, atomic log write that never
# loses a capture append. Touches no radio — pure CPU on saved clips. CLI: bot-recover.py [--once]
import os, sys, glob, time, json
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "eot"))
import hot_explore as H # best_bits / preamble_end / vote — the proven recovery
CLIPS = os.path.expanduser(os.environ.get("BOT_CLIPS", "~/istrain/bot_clips"))
LOG = os.path.expanduser(os.environ.get("BOT_LOG", "~/istrain/bot.jsonl"))
STATE = os.path.join(CLIPS, ".recovered.json")
MIN_AGE = int(os.environ.get("RECOVER_MIN_AGE", "60"))
INTERVAL = int(os.environ.get("RECOVER_INTERVAL", "30"))
MIN_Q = float(os.environ.get("RECOVER_MIN_Q", "0.90")) # only keep a genuinely clean frame (3-way agreement)
def _hot_address(block, sync="0110101010", off=49, ln=17):
"""CRACKED field — the addressed EOT unit. HOT comes through with opposite polarity to EOT, so invert,
align to the sync, and read 17 bits LSB-first. Validated 2026-06-28: 22/22 recovered frames give a
plausible address and 9 match units decoded independently on 457 (chance ~1e-8). See EOT-HOT-PROTOCOL.md.
The command/type + BCH fields are still open — this is the address only."""
ib = block.translate(str.maketrans("01", "10"))
j = (ib + ib).find(sync)
if j < 0:
return None
frame = (ib + ib)[j:j + len(block)]
bits = (frame + frame)[off:off + ln]
if len(bits) < ln:
return None
return int(bits[::-1], 2)
def _hot_recover(clip):
"""Clip -> {hot_block, hot_q, hot_period, hot_addr} for a clean recovered HOT frame, or None."""
mx, bs = H.best_bits(clip)
body = bs[H.preamble_end(bs):]
if len(body) < 3 * 60:
return None
r = H.vote(body)
if not r:
return None
p, off, block, errs, agree, maxa = r
q = agree / maxa
if q < MIN_Q:
return None
# reject a preamble-dominated lock: the voter can latch the alternating 0101… preamble (trivially
# high agreement). A real HOT data block (30 data + 33 BCH) isn't ~all transitions.
trans = sum(1 for i in range(1, len(block)) if block[i] != block[i-1])
if trans / max(1, len(block) - 1) > 0.85:
return None
rec = {"hot_block": block, "hot_q": round(q, 2), "hot_period": p}
addr = _hot_address(block)
if addr is not None:
rec["hot_addr"] = addr
return rec
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(recovered):
"""Merge {sec: {hot_block,...}} into bot.jsonl by integer-ts match — atomic, never drops an append."""
if not recovered 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 recovered: r.update(recovered[s])
out.append(r)
with open(LOG) as f: _take(f)
try:
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():
if not os.path.isdir(CLIPS):
return (0, 0)
state = _load_state(); now = time.time()
recovered = {}; tried = ok = 0; newly = []
for c in sorted(glob.glob(os.path.join(CLIPS, "bot_*.s16"))):
fn = os.path.basename(c)
if fn in state: continue
if now - os.path.getmtime(c) < MIN_AGE: continue
try: r = _hot_recover(c)
except Exception as e: sys.stderr.write("[bot-recover] err %s: %s\n" % (fn, e)); continue
tried += 1; newly.append(fn)
if r:
ok += 1
try: sec = int(time.mktime(time.strptime(fn[4:-4], "%Y%m%d_%H%M%S")))
except Exception: sec = None
if sec is not None: recovered[sec] = r
sys.stderr.write("[bot-recover] ✓ %s frame q=%s block=%s\n" % (fn, r["hot_q"], r["hot_block"][:16]))
if recovered: _enrich(recovered)
if newly: state.update(newly); _save_state(state)
return (tried, ok)
def main():
if "--once" in sys.argv[1:]:
t, o = one_pass(); print("[bot-recover] one pass: tried %d, frames recovered %d" % (t, o)); return
sys.stderr.write("[bot-recover] HOT frame-recovery watcher up — %s every %ds (no cull)\n" % (CLIPS, INTERVAL)); sys.stderr.flush()
while True:
try:
t, o = one_pass()
if t: sys.stderr.write("[bot-recover] pass: tried %d, recovered %d\n" % (t, o)); sys.stderr.flush()
except Exception as e:
sys.stderr.write("[bot-recover] pass err: %s\n" % e); sys.stderr.flush()
time.sleep(INTERVAL)
if __name__ == "__main__":
main()