559caead36
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>
139 lines
5.7 KiB
Python
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()
|