#!/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()