14. Version History
Unreleased — 2026-08-08 — band-hunt Entity-Name Normalization (pskreporter → cty)
- 现场根因(12h 复盘):band_hunt 反复切 15m 追 “Germany”,但 Germany 实体早已通联(qso 表 64 条 DL/DK 记录)。原因:
rank_bands的 worked 过滤直接用 pskreporter 实体名比对 cty.dat 规范名——pskreporter 用普通名 “Germany”/“Malaysia”/“Turkey”,cty 规范名是 “Fed. Rep. of Germany”/“West Malaysia”/“Asiatic Turkey” → 名称失配 → 已通联实体被判 new。实测 42 个 pskreporter 实体名中仅这 3 个不一致。 - 修复:
band_hunter.py加_CTY_NAME_ALIASES别名表 +_canonical_entity_name()(未知名保持原样,不误伤);rank_bands的 worked 判定改用规范化名,new_entities保留原名显示。auto_call 的is_new_dxcc不受影响(两侧都用 cty.lookup,本就一致正确)。 - Regressions:
test_band_hunter.py(Germany/Malaysia/Turkey 别名归一化 + 未通联保留 + 未知名称不变)。全量套件绿(768 passed)。
Unreleased — 2026-08-07 — Serial-Owner Guard: refuse rigctld start on device conflict (AD-008)
- 现场根因(4 小时 rig 劣化复盘):18:01 一台手动
nohup启动的旧 mrrc_ft710server.py(MacPorts 系统 Python,非 start.sh 的 venv 解释器)直接 open 了 FT-710 CAT 串口/dev/cu.usbserial-0121DB3A0,与 rigctld 同时持有 → 字节级争抢 → rig 轮询 90% 超时(18:00–22:20,wrong reply/Rig busy数千条),拖累 band_hunt 切频 5 次失败。 - 暴露的漏洞(switch.sh/stop.sh,旧项目):
ft710_running()与stop.sh的pgrep -f "python.*server\.py"大小写敏感,匹配不到Python server.py(MacPorts 解释器首字母大写)→ 误判未运行 → 切换时漏停残留进程;停止逻辑依赖 pid 文件 + WEB 端口,非 start.sh 启动的进程两者皆无。 - 修复(ft8 侧):
restart.sh新增serial_guard()——启动 rigctld 前检查 CAT 串口是否被非 rigctld 进程持有(lsof -t $RIG_DEVICE),冲突即拒绝启动并列出持有者(fail-fast),MRRC_FT8_SKIP_SERIAL_GUARD=1应急跳过;tests/test_deploy_artifacts.py文本回归。 - 修复(mrrc_ft710 侧,旧项目):
switch.sh/stop.shpgrep 改大小写不敏感(pgrep -if "server\.py"),ft710_running()加串口持有者语义兜底(非 rigctld 持有即视为残留),stop.sh加 section 5 串口持有者释放(保留 rigctld)。 - 现场处置:停掉 56041 后 rig 立即恢复(连续轮询成功);本文档 V1.3 频率纪律修复(UC-003/004)不受影响。
- Regressions:
test_deploy_artifacts.py(restart.sh 守卫文本回归)。全量套件绿。
Unreleased — 2026-08-07 — CQ Picks an Unoccupied Offset near 1500 Hz (UC-004)
- 现场根因:回复频率跟随伙伴后(同日 UC-003 修复),主动 CQ 仍固定 1500 Hz——与占用该频点的他台信号重叠时双方解码互相掩盖(CQ 收不到应答)。
- 修复:新增
server/engine/tx_frequency.py——FrequencyOccupancy占用环(120 s TTL,main.py on_decode 对每个解码频率 note,含自身回波——频点确实承载我方信号)+ 纯函数pick_cq_frequency(螺旋扫描:1500 → 1501 → 1499 → …,与所有占用中心距 >= 30 Hz guard 的首个整数频点;窗口 1500±300 内全占用则回退默认)。Sequencer.start_cq(tx_frequency=…)接受调用方频点;显式/operation/cq与 cq_loop(pick_frequency注入,每次 CQ 启动与 re-CQ 重选)都走 picker;AppState.occupancy组装。 - Regressions:
test_tx_frequency.py(纯函数螺旋/guard/窗口/回退 + 占用环 TTL 边界)、test_cq_loop.py(picker 注入、DONE re-CQ 重选)、test_api.py(/operation/cq 避开占用、空波段回默认)。全量套件绿(762 passed)。
Unreleased — 2026-08-07 — Reply Frequency Follows the Partner (UC-003 RX-offset half)
- 现场根因(复盘链):
TxDriver的 TX 音频频率写死 1500 Hz(DEFAULT_TX_AUDIO_FREQUENCY),从不跟随被叫台解码频率。TN8GD(尼日尔,新 DXCC)09:46 auto-call 命中其 f=843 Hz 的 CQ,应答却发在 1500 Hz → 对方(pileup 模式的自动化 DX 台)未配对,重传 4 次无应答、RETRY_EXHAUSTED 丢弃未落库;随后 TN8GD 转去通联 JA4AQS/VU2KPH。对照:成功 QSO 均因对方人工在自己频率(1600/1401)应答;同样只有 1500 回波的 UA4LDP 同样未完成。RX 侧一直解码到自身 1500 Hz 发射回波(mine过滤正常,佐证 TX 频率确为固定 1500)。 - 修复:Sequencer 持有每 QSO
tx_frequency(reply_to(…, tx_frequency=…)记录伙伴解码频率;start_cq与_reset_partner回默认 1500)。TxDriver编码改读sequencer.tx_frequency(删掉自身tx_audio_frequency字段);DEFAULT_TX_AUDIO_FREQUENCY归口 sequencer.py(tx_driver 别名导入,单一事实源)。接线:operation/select与operation/reply透传 body 的freq(UI candidate 已带freq;_parse_freq校验 >0 的 int/float,bool 拒绝);auto-call 用 view 的freq;AppState.selected_freq随 QSO 结束清理。旧客户端不带freq时保持历史 1500 Hz 行为。 - Regressions:
test_sequencer.py(reply_to 记录伙伴频率/缺省默认/start_cq 复位)、test_tx_driver.py(应答编码频率=伙伴频率、CQ=默认频率)、test_api.py(select/reply 透传 freq、缺省回退)、test_main.py(auto-call 用解码频率/无 freq 回退)。全量套件绿(747 passed)。
Unreleased — 2026-08-06 — Selected-State Leak Gating Band-Hunt/Auto-Call (P7)
- 根因(现场复现链):
state.selected只有赋值、从不置 None(grep 无selected = None)。用户点选/回复一次后 selected 永久残留 → band-hunt 与 auto-call 的selected is None门永久关闭(诊断日志:00:26 seq=idle 但 selected=True;audit 无 band_hunt 记录)。开关、pskreporter、fetch/rank/decide 链路均正常(模拟验证 target=21074000),断点在 selected 门。 - 修复:
Sequencer新增on_stop回调(stop 与 complete 时 fire,带 DisarmReason);main.py挂接后清state.selected/selected_slot_id/selected_snr_db。仅 QSO 生命周期结束(完成/中断)清除;只 select 不 reply 的高亮保留。 - 联动验证:修复后 band_hunt 立即动作——切到 15m(21.074 MHz),new DXCC Germany,方案 A 主动 capture 重启联动,audit 记录 band_hunt。auto-call 同窗口在 P7 修复前的旧进程已观察到成功触发(TN8GD),修复后门解除。
- Regressions:
test_sequencer.py(on_stop fire 时机/原因)、test_main.py;全量套件绿(737 passed)。
Unreleased — 2026-08-05 — AUDIO False-Positive Re-Verify + New-DXCC Worked-Set Freshness
- B1 — 退化检测误判自愈:退化重启 capture 后开启 2-slot 复验窗口——重启后的新流仍热+零解码说明退化检测误判(波段上本无 FT8 内容,如夜间 40m 强语音台;现场 22:11 切 40m 后 fault 锁 90 分钟),自动清除 AUDIO fault。TX 仍需操作员手动重新 arm,不违反 no-recovery-auto-resumes-TX(§12)。误判只触发一次(
_triggered保持,直到恢复解码/静音才复位)。 - P1 — new-DXCC worked 集陈旧(auto-call 重复呼叫 + 识别滞后):
is_new_dxcc判定用启动预填的 DXCC cache;QSO 写库后(repository.dxcc_dirty)若 band-hunt 关闭则 cache 永不刷新 → 刚通联实体仍判 new,auto-call 重复呼叫同一站。修复:on_decode在判定前同步重建(仅 dirty 时,索引化后 ~0.2s/15s 至多一次);band_hunt_loop的刷新提前到auto_band_hunt开关检查前(auto-call 也依赖最新 worked 集)。 - 带外根因(用户侧):
auto_call_new_dxcc/auto_band_hunt后端开关从未开启(setting_meta 空表)→ auto-call 与 band-hunt 完全静默。需要操作员在 UI 开启;band-hunt 冷却默认 1200 s(MRRC_FT8_BAND_HUNT_COOLDOWN)限制切波段及时性。 - Regressions:
test_main.py(on_decode 刷新路径)、test_audio_rx.py/test_capture_proc.py(复验窗口)、全量套件绿(737 passed)。
Unreleased — 2026-08-05 — Proactive Band-Switch Capture Restart (AUDIO-Fault Prevention)
- 现场根因(2026-08-05):FT-710 切换波段后其 C-Media USB codec 的 RX 流会静默退化——流”热但零解码”(时间链错位),60 s 后触发退化检测 → AUDIO fault 锁 + TX 拒绝。实测:10:25 切 20m→15m 后 ring 出现
gaps=1,10:26 切 15m→17m 后解码全灭(rms 6938 零消息),10:28 退化 latch + capture 重启才恢复。 - 方案 A(根治路径):跨波段主动重启 capture。
rig_poll(外部/手动调谐)与 band-hunter 切频(内部自动)观察到拨号频率进入不同 FT8 波段时,立即重开 capture 子进程(新流永远干净),同波段幂等(每波段至多一次)、发射中 defer 到 RX 恢复后补执行、服务器启动首轮仅建基准。避免整个退化→锁 TX→人工解除的周期。 - 附带修复:
CaptureProcess.healthy加锁对齐restart()——watchdog 原本在锁外检查,会撞上 teardown→spawn 的_process is None中间窗口,在主动重启后 3 ms 内二次重启(现场 caprestarts 0→2);加锁后互斥,实测切 10m/回 20m 各只重启一次(caprestarts 0→1)。 - Regressions:
tests/engine/test_capture_proc.py(healthy 加锁后 watchdog/restart 交互)、tests/web/test_main.py(rig_poll/band_hunt 改动路径)、全量套件绿(737 passed)。
Unreleased — 2026-08-05 — New-DXCC Hunt Reaction Optimization
- 实测基线(生产 DB 10,533 QSO):
cty.lookup单次 1.5 ms(线性扫描 346 实体 × ~38.5k 前缀);dxcc_summary全量重建 10.2 s(6,749 次 lookup × 2.59 亿 startswith);每次 QSO 写入后/dxcc、/band-hunt首次请求被 10 s+ 重建阻塞;dashboardPromise.all等最慢深窗口(3/7 天冷缓存 5-8 s)。 CtyDatabase.lookup索引化:__post_init__构建_exact(=CALL精确优先)+ 前缀 trie(最长前缀;(数字)替换已在加载时展开),setdefault保留并列最先遇到。语义与旧线性扫描逐字节等价(语义一致性回归:cty.dat 全展开模式 + 2000 合成呼号 + 真实 QSO 日志 dx_call 全量比对)。lookup 1.5 ms → 亚微秒;dxcc_summary重建 10.2 s → ~0.2 s。- 去掉 New-DXCC dashboard 的 3/7 天深窗口(
BAND_HUNT_WINDOWS收窄到 10/30 分钟、1/4 小时、1 天)。 - 抽
_fresh_dxcc_cache(state)共享刷新助手(/dxcc、/band-hunt、启动预填、band_hunt_loop四处复用);修复自动波段猎人陈旧 worked 集——每 tick 先刷新,刚通联的实体不再被重复 hunt。 - dashboard 渐进渲染:所有窗口 fetch 并行、先到先渲染(顺序保持),10 分钟窗口 ~1 s 内可见。
/band-hunt代理加上游 TTL 缓存(_BandHuntCache,键(window_min, grid, radius, detail),TTLmin(window_min, 3600)s,只缓存ok:true原始 body,worked 过滤仍每请求实时跑);dashboard 在 TTL 内二次打开免冷拉,band_hunt_loop 保持每 tick 新鲜拉取。- Regressions:
test_dxcc.py(索引语义等价 + 速度烟测)、test_api.py(_fresh_dxcc_cachedirty/clean/新 QSO、_BandHuntCachehit/miss/过期/驱逐、代理二次请求命中缓存、错误不缓存)、既有band_hunt_proxy_*全绿。全量套件绿。
Unreleased — 2026-08-05 — Manual Reply Races the Current Slot
- 现场根因(production DB 佐证):手动 Reply 只有在当前时隙开始后 ~2.2 s 内(fit guard:12.64 s 波形必须塞进 15 s 时隙)武装才能当拍发射;而解码行要到时隙开始后 ~0.67 s 才上屏(0.4 s 投递宽限 + 解码),双击又是 select→(lease)→select→reply 2–4 个 HTTP 往返,操作员实际在 +4 s/+7 s/+13 s 才点中,永远错过窗口,回复推迟到下一个同相时隙——实测 26–38 s 后才发射(S54E 38 s、BA6CC/OE4AHG 26 s)。
- 三处修复:
Orchestrator解码投递改为边界起轮询 ring(_wait_for_slot,25 ms 步长),数据一到立即解码,DELIVERY_GRACE_SECONDS语义从固定等待改为超时上限;解码上屏提前 ~0.3 s,且on_slot_start(TxDriver 决策窗)也提前 ~0.3 s 打开。operation/reply接受内联候选(dx_call/grid/snr/slot_id),双击 = 一次往返 select+arm;前端api.reply(candidate)去掉独立 select 往返,lease 缺失时同 select 一样隐式获取。- 回复响应带
scheduled_tx(fit 感知的下一发射时隙slot_id/utc/deferred),前端在推迟时 toast「Reply armed → TX at HH:MM:SS」——操作员不再盲目连点(现场曾 9 s 内连点两次 OE4AHG)。
- 物理边界不变:迟于 ~2.2 s 的点按仍只能等下个同相时隙(对方只在相反相隙收听);本次把「能当拍」的窗口和路径拉满到物理极限,并把推迟情形显性化。
- Regressions:
test_orchestrator.py(数据就绪即投递 + 超时跳过 + 原 grace 断言改为边界投递)、test_api.py::test_reply_with_inline_candidate_arms_in_one_request。全量套件 726 passed。
Unreleased — 2026-08-05 — Propagation-Driven New-DXCC Band Hunt (NFR-088)
- 新功能:
band_hunter让服务器自主换波段去守新 DXCC。通过 pskreporter 仓库的GET /api/band_hunt(HTTP 是两库间唯一耦合,严格隔离:不 import、不共享 DB 凭据)拿到”附近网格(距本网格 ≤ radius_km)此刻在听的 FT8 波段”;本地用 worked 实体集过滤出仍有新 DXCC 的波段排序;空闲且开关auto_band_hunt开启时把 rig 切到榜首波段,再由 auto-call(NFR-087)闭环通联。 - 护栏:仅空闲切频(同 auto-call 门禁:sequencer IDLE 且无人工选择)、min-dwell 冷却(默认 1200 s)防抖、
safety.armed/ptt_on时绝不调谐、端点失败本轮跳过不崩溃、无 worked 集不盲目切换、每次切换 audit(band_hunt)。 - 配置:
MRRC_FT8_BAND_HUNT_URL(默认空 = 功能整体不加载)+RADIUS_KM/WINDOW_MIN/INTERVAL/COOLDOWN(越界值回退默认,不 fail-startup);设置菜单栏加auto_band_hunt开关(后端持久化,/settings)。 - 波段扩展:FT8 波段选择从 7/14/21/28 MHz 扩到 7/10/14/18/21/24/28 MHz(FT8 呼号频点);pskreporter
band_utils新增FT8_DIAL_FREQ单源表。 - 依赖:
httpx从 dev 移到运行时依赖(生产轮询用,默认 5 s 超时)。 - 驾驶舱看板:Auto band hunt 开关下加「New-DXCC spots dashboard」链接 → 全屏看板经
/api/v1/band-hunt同源代理(服务端 httpx 转发 pskreporter,跨库仍仅 HTTP)拉取 10/30 分钟 / 1/4 小时 / 1/3/7 天 七个窗口的邻近 spot 明细(每窗口展示最新 200 条)。「spots」概念统一为新 DXCC spots:代理用权威 worked 实体集(canonical 日志 → cty.dat)过滤,只保留未通联实体 spot、丢弃全已通联的波段,并返回new_spot_count/worked_spot_count供上下文(pskreporter/api/band_hunt?detail=1返回含已通联的原始逐 spot 列表,过滤在 FT8 侧统一进行);看板不再各自过滤,标注「N new-DXCC spot(s) · M worked nearby」。 - 深窗口支持:pskreporter
/api/band_hunt的window_min上限从 1 天扩到 7 天,查询ORDER BY qso_time DESC并按窗口缩放LIMIT(≤3 天 300k / 7 天 500k)保证大窗口取最新数据;实测 1/3/7 天邻近 spot 263 / 819 / 1155 条。代理/api/v1/band-hunt超时从 5 s 提到 20 s(深窗口首次未缓存查询耗时可达 ~5-8 s,原超时导致band_hunt_unreachable)。 - Regressions: mrrc-ft8
tests/engine/test_band_hunter.py(rank/decide/fetch 矩阵)、test_main.py::test_from_env_band_hunt_optional、test_api.py(auto_band_hunt round-trip + 422、/band-hunt代理 not_configured + 转发参数/信封);pskreportertest_band_hunt.py(距离门/阈值/DB 异常/detail spots)。全量套件绿(mrrc-ft8 721 passed)。
v1.1.0 — 2026-08-05 — DXCC Live View + New-DXCC Auto-Call
- Promoted the NFR-085/086/087 slice to v1.1.0 (pyproject 1.1.0, tagged
v1.1.0): JTDX ADIF auto-sync + LOG 7-day window (NFR-085), the in-cockpit DXCC live view with cache-until-QSO-write stats (NFR-086), and the new-DXCC highlight + safety-armed auto-call toggle (NFR-087). Also shipped: the full-screen QSO Log overlay and operator-callsign de-identification across README/SDD/website. GitHub release notes enumerate the v1.0.0 → v1.1.0 diff.
v1.0.0 — 2026-08-04 — First Public Release
- Promoted the live station build to v1.0.0. The 2026-08-03 field session closed the RX/TX root causes (UtcRing eviction misalignment, Replay opposite-TX-slot phase, manual-Reply decision window); on 2026-08-04 the FT-710 rig controls were completed (filter width + ATT/PREAMP/AGC/RF gain via raw CAT, verified live on hamlib 4.7.2). Real FT8 QSOs are worked through the mobile cockpit. Tagged
v1.0.0; the GitHub release notes enumerate the 2026-08-01 design baseline through the 2026-08-04 rig-control completion.
v1.1.0 — 2026-08-04 — New-DXCC Highlight + Auto-Call
decode_message_view增加is_new_dxcc(cty lookup + worked 实体集合,复用 dxcc_cache,lifespan 预填充);Band Activity 新实体行紫色高亮(.candidate.new-dxcc)。auto_call_candidate纯函数 +on_decode触发:开关开 + 空闲 + 无人工选择 + 第一个新 DXCC CQ →safety.arm()→sequencer.reply_to(create_task 异步,auditauto_call);QSO 完成实体进 worked 自然不再触发;interlock 拒绝跳过不崩溃;无需控制租约(系统级,NFR-087)。- 设置
auto_call_new_dxcc(bool)入 SETTING_SCHEMA,走/settingsPUT/GET 持久化;FT8 tab toggle(localStorage 回显 + 后端 PUT,boot 时后端值覆盖默认);get_cty_database()懒加载单例供 API/自动呼叫共用。 - Regressions:
test_main.py(is_new_dxcc 判定、auto_call_candidate 矩阵:触发/禁用/忙/非CQ/mine)、test_api.py(设置 round-trip + 422)、test_dxcc.py(单例)。全量套件绿。
v1.1.0 — 2026-08-04 — DXCC Stats: Cache-until-QSO-Write
- 决策 A 落地:DXCC 是低频数据,
/api/v1/dxcc不再每次打开全量扫描。Repository增加dxcc_dirty标记(初始 True;record_qso/import_qsos/void_qso置 True,读操作不置);AppState.dxcc_cache持有结果;接口在cache is None or dirty时经to_thread重建并复位,否则直接返回缓存。QSO 写入(新通联/每小时 JTDX 导入/撤销)后下次打开自动重算(1 万条 ~10ms),重启后首次打开重算一次。 - Regressions:
test_repository.py::test_dxcc_dirty_flag_tracks_qso_writes(初始/写入/读不置)、test_api.py::test_dxcc_cached_until_qso_write(首次计算→缓存命中→新实体触发重算 total+1)。全量套件绿。
v1.1.0 — 2026-08-04 — DXCC Stats + Menu Live View
- 新模块
server/engine/dxcc.py:自写cty.dat解析器(country-files 旧版 ADIF 格式,实体行 + 跨行续行前缀列表,=精确匹配 /(23)数字替换 / 最长前缀匹配)。pyhamtools 0.13.0 可行性验证被否决(其 LookupLib 只支持 cty.plist / clublogxml,需联网且引入 5 个依赖),零新依赖。真实数据冒烟:6,740 个去重呼号 → 186 实体,2 个特殊呼号未匹配(D1 活动台);数字替换语义修正后 B0/B9 省际前缀正确归 China。 dxcc_summary全量扫描非 void QSO:实体总数、每实体首通/最近通联(UTC ISO)、波段集合(同实体×同波段计 1,DXCC Challenge 语义)、by_band每波段实体数。波段分布:15m 146 / 20m 141 / 40m 136。GET /api/v1/dxcc(认证,_ok信封)实时计算返回;cty.dat懒加载单例(parents[2]定位仓库根);打开即最新,无 WS 推送/轮询(NFR-086)。- 前端:设置菜单加 DXCC tab → 全屏 overlay(镜像 LOG):大数字总数 + 波段矩阵(降序)+ 实体列表(名称/洲/首通/波段数);
api.js加dxcc()。 - Regressions:
tests/engine/test_dxcc.py(解析/查找/统计 + 真实数据冒烟)、tests/web/test_api.py(认证 + 信封 + 结构)。全量套件绿。
v1.1.0 — 2026-08-04 — JTDX ADIF Auto-Sync + LOG Recent-Week Window
- Operator has ~10,263 historical QSOs (2023-02-27 → 2026-08-02, all BG1SB, FT8 + 6 FT4/MFSK, 2m–80m) in
~/FB/JTDX/wsjtx_log.adi; the canonical store only held 23 live QSOs. Newserver/engine/adif_import.pyparses the JTDX export tolerantly (skips the header and any half-written trailing line while JTDX is mid-write, normalizesmy_gridsquarecasing, maps MHz strings to Hz) andsync_jtdx_logimports it additively and idempotently: dedupe key(dx_call, utc date, started_utc, band)collapses JTDX’s 31 same-second duplicate attempts (verified on the real file: 10,263 parsed → 10,232 inserted, 31 skipped) and is cross-source, so a live-completed QSO never gets re-imported when JTDX later records the same QSO. main.pygainedMRRC_FT8_JTDX_LOG_PATH(empty = disabled): one import at startup, then an hourly background task; a missing file only warns and the next tick retries; never faults the safety controller. Every sync writes an audit row (jtdx_import, detailinserted=.. skipped=..).- Schema v1→v2:
qso.source('live'/'jtdx') added with an in-place ALTER migration that preserves existing rows;Repositorygainedimport_qsos(bulk, one transaction) anddedupe_keys(cross-source by default). - LOG surfaces are windowed to the last 7 days on the backend (NFR-085):
GET /logs/qsosandGET /logs/adifboth filtercompleted_epoch >= now-7d, so the 10k-row history never reaches the browser; the ADIF export is windowed too (operator decision).worked_calls(hide already-worked) still reads the full history (6,693 base calls after import). - Regressions:
tests/engine/test_adif_import.py(parser tolerance, mapping, dedupe collapse, idempotent/incremental sync, live-QSO protection, missing-file report);tests/engine/test_repository.py(v1→v2 migration preserves rows, import source/epoch, cross-source keys,since_dayswindow);tests/web/test_api.py(both LOG endpoints windowed);tests/web/test_main.py(env parsing, startup import, disabled no-op). Full suite green.
v1.1.0 — 2026-08-04 — QSO Log Overlay Renders (missing ok envelope fixed)
- Field report: the drawer’s Log tab always showed an empty/error view even though the canonical store held completed QSOs. Root cause:
GET /logs/qsosreturned a bare{qsos, revision}body while every other endpoint (mutations,/radio/rig/levels,/session/current) returns the_okenvelope{ok: true, …}; the drawer’sapi.qsos()gate isres.ok, so a 200 without the key took the error branch every time (“Could not load log: 200”). - Fix:
logs_qsosnow returns_ok({qsos, revision}), matching the codebase envelope convention. The database-path question was also re-verified:mrrc-ft8.dbis cwd-relative and the restart wrapper starts from the project root, so the running server uses~/HAM/ft8/mrrc-ft8.db(23 completed QSOs, newest with freq/band after the sequencer-context fix). The DBMOVED self-heal (bb6f752) already covers the external-replace incident. - Regression:
test_qso_listing_and_audited_voidassertslisting["ok"] is Truealongside the existing payload check; fulltests/web/suite passes.
v1.1.0 — 2026-08-04 — Full-Screen QSO Log Overlay
- The drawer’s Log tab now opens a full-screen QSO log overlay: recent QSO list plus an ADIF export link, styled into the static shell with no build step (design + plan:
docs/superpowers/specs/2026-08-04-log-page-design.md). Sequencer QSO context wires the completion-time rig frequency / ADIF band so rows carry band info; the overlay renders becauseGET /logs/qsosreturns the_okenvelope (entry below).
Unreleased — 2026-08-04 — FT-710 Rig Levels (ATT/PREAMP/AGC/RF): Raw-CAT Read/Write Through rigctld
- Field report: after the filter-width fix and the hamlib 4.7.2 upgrade, the drawer’s filter selector works but ATT/PREAMP/AGC/RF gain do not —
POST /radio/rig/levelreturns success yet the rig never changes. Root cause was not the CAT frames (the filter bug was) but hamlib’s level abstraction itself: the FT-710 never answersL <name>(times out / drops the session) and thel <name> <value>write path is equally unreliable, so the level readback reported the rig’s stored state while the write silently did nothing or failed. - Verified live against the station rigctld (4.7.2, SHA 40f63488f) with raw
\send_rawprobes, including an interleaved write→readback→restore sequence for every level. The raw CAT path works for both directions; the FT-710’s actual codes were confirmed empirically:- ATT
RA00;=off /RA01;=6 /RA02;=12 /RA03;=18 dB — matches hamlib 4.7.2’snewcat_set_level. - PREAMP
PA00;=off /PA01;=10 /PA02;=20 dB — matches hamlib. - AGC
GT00;=OFF /GT01;=FAST /GT02;=MED /GT03;=SLOW /GT06;=AUTO — hamlib sendsGT04;for AUTO, which the FT-710 ignores (probingGT04/GT05normalizes toGT06); another confirmed hamlib mapping bug. - RF gain
RG0<000..255>;= round(value × 255) — matches hamlib’slevel_granstep 1/255. - TX power via
PC0…;is rejected by the FT-710 (?reply), matching hamlib 4.7.2’sNO_LVL_RFPOWERinft710.c— deliberately not exposed.
- ATT
- Fix (AD-008 intact — rigctld stays the sole serial owner):
RigClient.get_level/set_levelnow prefer raw CAT frames through\send_rawfor ATT/PREAMP/AGC/RF (value→code and code→value tables verified above), falling back to the hamlibL/lpath for other levels, non-FT-710 rigs, and out-of-set values. Raw writes drain any residual reply with a 50 ms quiet check so a stray rig answer can never corrupt the next command. POST /radio/rig/levelnow drops the 60 s level snapshot on success, so the drawer reflects the rig’s real state on the next open instead of a stale readback.- Regressions:
FakeRigctldanswers\send_rawfor RA/PA/GT/RG (write = silent, read =<prefix>0<code>;), mirroring the live rig; the engine tests cover raw read/write for all four levels, the hamlib fallback for out-of-set values, and the caps-dump drain on the fallback path; the API test asserts the level cache is invalidated after a write.
Unreleased — 2026-08-04 — FT-710 Filter Width: Corrected Root Cause, Raw-SH Fix Through rigctld
- The “filter width does not stick” investigation concluded with a corrected root cause (full record:
docs/FILTER_WIDTH_ISSUE.md; the earlier direct-serial workaround ca9ec89 was based on a misdiagnosis and is reverted). Hamlib 4.6.2’s FT-710 backend (Yaesu newcat) is broken in both directions:- SET:
newcat_set_rx_bandwidthmisses the FT-710 in its 4-digit command branch and emitsSH0NN;instead ofSH00NN;— the rig ignores the malformed frame, soM <mode> <width>returns RPRT 0 yet never changes the width. - GET:
newcat_get_rx_bandwidthhas no FT-710 branch; the SH index falls into the FT-450/FT-9000 narrow/normal/wide bucketing, somreports 2400 Hz (index 14) as 1800 Hz. The supposed “1-3 s revert” is just the 500 ms hamlib set-cache expiring and exposing this misread — the rig never reverts; the supposed rigctld-restart reset is the same misread (newcat_opennever touches SH).
- SET:
- Fix (AD-008 intact — rigctld stays the sole serial owner):
RigClient.set_filter_widthwrites the correctly framedSH00<NN>;via rigctld’s\send_raw 0pass-through;RigClient.get_filter_widthreads the true width via\send_raw ; SH0;and maps it with hamlib’sftdx101_ssb_widthstable.GET /radio/modenow prefers the raw SH read over hamlib’s misreported passband;POST /radio/modeapplies the width through the raw path as well (best effort);POST /radio/filterstays the dedicated width intent. - Removed the direct-serial machinery (pyserial/requirements.txt,
FT710_FILTER_SERIAL_PORT), corrected the width index table (2400 → index 14; the committed 13 = 2300 Hz was wrong), and verified live against the station rigctld with interleaved raw reads while the server polled. Regressions: FakeRigctld only accepts 4-digitSH00NN;frames (the rig ignores malformed ones), and the API tests include the “hamlib claims 1800, register holds 2400” readback scenario. - Also fixed a stale SDD-harness assertion that still expected SDD version V1.0 after the V1.1 refresh.
Unreleased — 2026-08-03 — Level-Probe Fast-Fail + Diagnostic Logging
- The real culprit behind “filter switch does nothing”: the server had been restarted BEFORE the rig.py/api.py fixes landed, so it kept running the old code (level probes holding the rig lock 8 s, stale RPRT poisoning reads). With the fixes deployed, a drawer open costs one 0.55 s probe (first unsupported level stops the batch), filter switches take ~0.25 s and read back correctly.
RigClient._query/_readline_lockedaccept a per-commandtimeout;get_levelprobes with 0.5 s and treats timeout/unsupported identically (write pathl <level> <value>unaffected)./radio/rig/levelsprobes serially and stops at the first failure, caching the result for 60 s.radio_modeand the rig invalid-token errors now log the failure + raw payload (log.warning/ richerRigErrordetail) so field diagnosis no longer needs to guess.
Unreleased — 2026-08-03 — Queries Skip Stale RPRT (final filter-switch fix)
- Field report #3 with diagnostics:
GET /radio/modereturned 502 withrig_rprt— rigctld rejectedm. Root cause: FT-710 never answersL <level>queries; the timed-out command’s delayedRPRT -11(unsupported) arrives after we drop and reconnect, landing on the new session before them/freply.get_modereadRPRT -11as its reply → 502. The write (POST /radio/mode) succeeded — the 3 kHz selection actually applied, only the readback failed. - Fix:
_query(..., skip_stale_rprt=True)onm/f/treads skips up to 8 leading RPRT lines (stale replies from prior commands) before taking the real payload;Lqueries keep strict RPRT handling so a genuine unsupported-level error still surfaces asrig_unsupported. Regression: fake rig injects a staleRPRT -11before the firstmand the client still reads mode+frequency correctly.
Unreleased — 2026-08-03 — Radio Drawer Takes the Control Lease Implicitly
- Field report #2: filter-bandwidth switches kept returning 409 Conflict even with no TX active. Root cause:
/radio/modeand/radio/rig/levelrequire the control lease (require_lease), but the settings drawer never acquired it — every toggle before a lease was held returnedlease_required(36 consecutive 409s in the log). Candidate taps had implicit lease acquisition (UC-002); the drawer now reuses the same pattern:ensureLease()takes a free lease before any rig mutation, shows “Control is held by another session” when another session owns it.
Unreleased — 2026-08-03 — Rig Level-Query Isolation (no more filter-switch corruption)
- Field report: switching the filter bandwidth right after opening the Radio drawer failed intermittently (GET /radio/mode 502, rig_poll “invalid frequency”). Root cause: FT-710 never answers the
L <level>query — every attempt timed out and dropped the rigctld session, and the reconnects corrupted concurrent rig_poll / mode traffic for minutes. - Fixes:
RigClientdrains stale bytes on a freshly-reopened session (_drain_stale_input), so a reconnect never reads the previous session’s leftover reply./radio/rig/levelscaches results for 60 s (AppState_rig_level_cache): once a level read fails it is treated as unsupported instead of hammering the rig on every drawer open; the drawer still allows writes (l <name> <value>), which FT-710 answers.
- Verified live: 4 level reads time out (expected), then frequency polls and mode switches are clean; USB bandwidth 1.8/2.4/3.0 kHz round-trips after the failed reads.
Unreleased — 2026-08-03 — Rigctld Protocol Hardening (real FT-710)
- Live verification against the station’s rigctld (
-m 1049FT-710) exposed three protocol mismatches the fake rig tests never caught; all fixed and regression-tested:l <level> <value>replies with the new value (6.), notRPRT 0—set_levelnow accepts both reply styles.l PREAMP …(and some other levels) trigger a full caps dump (438 lines) ending inRPRT 0;set_leveldrains until RPRT so the dump never corrupts the next command.mrepliesMODE.passband.on one line (USB.1800.), not two lines;get_modeaccepts both shapes.- FT-710 rigctld appends a blank line after every set reply (
RPRT 0\n\n);_readline_lockednow skips blank lines and supports an unread buffer.
- Live confirmations: filter bandwidth USB 1.8/2.4/3.0 kHz round-trips, ATT 0/6, PREAMP 0/10, AGC 0..6 (OFF/FAST/MED/SLOW/AUTO), RF 0..1 — all set and read back consistently.
Unreleased — 2026-08-03 — Radio Tab: Filter Bandwidth + Rig-Accurate Levels
- Radio tab now reads the rig’s actual capabilities instead of generic sliders: verified against the FT-710 Hamlib model (rig 1049, Hamlib 4.6.2) which exposes USB/LSB bandwidths 1.8/2.4/3.0 kHz, attenuator 6/12/18 dB, preamp 10/20 dB, discrete AGC (OFF/FAST/MED/SLOW/AUTO) and RF gain 0..1.
- New
GET /radio/mode+POST /radio/mode(rigctldM <mode> <passband>, the existingRigClient.get_mode/set_mode); the drawer shows the rig’s current passband and switches bandwidth in place. - ATT/PREAMP are discrete selects (dB values), AGC is the five-mode select, RF Gain a 0..1 slider — matching the rig caps instead of the earlier generic 0-100 toggle/slider.
- New
Unreleased — 2026-08-03 — Settings Drawer (Radio / FT8 / Station / Log)
- Left-side drawer menu in the cockpit (hamburger ☰ in the top bar) with four tabs:
- Radio: rig levels via new rigctld level endpoints — ATT, PREAMP (toggles) and RF Gain / AGC (sliders), read/write through
L/lshort commands; unsupported levels surface as greyed-out (hamlibRPRT -11→rig_unsupported); TX must be off to change. NewGET /radio/rig/levels+POST /radio/rig/level;RigClient.get_level/set_level. - FT8: decode depth (Fast/Deep), colour scheme (Classic/High-contrast/Minimal), and display filters — show only CQ, hide already-worked calls (new-DXCC focus), hide own echoes. Persisted in
localStorage(mrrc-ft8.settings) and applied live to the Band Activity list. - Station: my call / grid / worked-call count from the snapshot’s new
stationblock;Repository.worked_calls()groups base calls (suffix-stripped) and excludes voided QSOs. - Log: QSO history list (
GET /logs/qsos, existing) plus an ADIF export link.
- Radio: rig levels via new rigctld level endpoints — ATT, PREAMP (toggles) and RF Gain / AGC (sliders), read/write through
- Snapshot gained
station: {my_call, my_grid, worked_calls};AppStatenow carriesmy_call/my_gridfromServerConfig.
Unreleased — 2026-08-03 — Sequencer State Shows Target Call
- The bottom safety bar rendered only the bare sequencer state (
replying,report…). It now showsstate → dx_call(e.g.replying → K1ABC) with the call highlighted in the accent colour, so the operator sees at a glance whom a manual Reply / QSO phase is aimed at.sequencer.dx_callwas already in the snapshot; only the client rendering changed (plus anescapeHtmlguard for the injected span).
Unreleased — 2026-08-03 — Repository DBMOVED Self-Heal + main.py QsoLog Integration
Production incident fix: an external process replaced
mrrc-ft8.dbmid-run; SQLite’s DBMOVED guard then refused every write withOperationalError: attempt to write a readonly database, turning reply/stop (which audit-write) into 500s while select (no write) stayed 200 — the “reply rejected 500” symptom.Repositorynow detects the readonly-DBMOVED error, reopens the (new) file at the same path, re-runs migration and retries the write once; a second failure propagates as a real disk problem. Regression:test_external_db_replace_does_not_wedge_writesswaps the file under a live repository and asserts the write lands.Completed the QsoLog integration Task 3 left half-done:
main.pystill imported the removedrecord_qsohelper. The composition root now buildsQsoLog(repository, pending_path=config.pending_path), wiressequencer.on_qso = qso_log.enqueue, drains one record per lease-watchdog tick (drain_once), recovers dead-letter records at startup and flushes on shutdown.ServerConfiggainedpending_path(envMRRC_FT8_PENDING_PATH). This unblocked 12 previously-failing lifespan tests.New end-to-end regression
test_reply_survives_external_db_replace: swaps the db under a live TestClient, replies, and asserts 200 + the audit row landed on the reopened file.Also fixed a pre-existing rig-poll bug found during restart verification:
main.pyunpackedfreq_hz, _ = await rig.get_frequency()but the rig client returns a plainint(TypeError every 5 s since the 08:12 boot, freezing the dial-frequency display). Now assigns the int directly; the poll loop keeps its monitor-only no-fault posture.
Unreleased — 2026-08-03 — Project Website (www.vlsc.net/mrrc_ft8/)
- Added
website/mirroring the mrrc_ft710 site format: dark theme homepage (EN + zh), SDD chapter index (generated bybuild_sdd.pyfromSDD/*.mdvia pandoc), live-station CTA, and the 15-chapter HTML set. Deployed tohttps://www.vlsc.net/mrrc_ft8/(nginx static root, same webroot family as the other VLSC project sites) and linked as an entry card in thewww.vlsc.netportal (EN + zh) plus the sharedglobal-nav.jsFT-8 link; the site’sdeploy.shmirrors the mrrc_ft710 one with the FT8 webroot.
Unreleased — 2026-08-03 — SDD Refresh to V1.1
- Brought the SDD body up to the implemented state: README quick facts (live deployment, v0.1.0), UC-003 marked implemented with the reply decision window, new UC-011 (FT8 band select), AD-006 (UTC ring absolute-index keying), §9.2/§9.3/§9.4 (3 kHz waterfall span, TX decision window + fit guard, dual cutoffs), §11 component table, §12 (public Host ACL / config), §15.6 (provisional TX decision), and the AGENTS.md/README module and status lines.
Unreleased — 2026-08-03 — Repo Tidy (v0.1.0)
- Removed the legacy
SDD-legacy-mrrc_ft710/archive (old design docs for the predecessor project; references inAGENTS.mdandSDD/02updated). - Untracked
wsjtx-3.0.2/(98 MB WSJT-X 3.0.2 Improved vendor source) from git and gitignored it, keeping it on disk so the DSP build (dsp/CMakeLists.txt), the vendor-policy tests and theALLCALL7.TXT/cty.datsymlinks keep working. Tagged the first GitHub push asv0.1.0.
Unreleased — 2026-08-03 — FT8 Band Selector (7/14/21/28 MHz)
- Added a band
<select>in the cockpit top bar (left of the decode-counter hourglass): 7/14/21/28 MHz, tuned to the FT8 dial frequencies (7.074 / 14.074 / 21.074 / 28.074 MHz) via the existing lease-gated/radio/bandmutation. Selection acquires a free control lease implicitly (UC-002, same as a candidate tap), rejects while TX is armed, toasts failures and reverts to the last known rig frequency. The control reflects the rig’s polled dial frequency when it lands on one of the four bands;band.jspopulates the options soindex.htmlstays logic-free.
Unreleased — 2026-08-03 — Waterfall Bandwidth = 3 kHz
SpectrumComputeremitted the full0..rate/2(0..6 kHz) spectrum, so the upper half of the waterfall canvas was always blank (the FT8 passband is ~0..3 kHz). The frame now emits onlyDISPLAY_BANDWIDTH_HZ(3.0 kHz) of bins; the client maps whatever bins it receives across the full canvas, so the span fills with no blank half (and the frame payload halves). Configurable viadisplay_bandwidth_hz; validated to stay within0..rate/2. Regression: emitted span is ~3 kHz, not 6 kHz.
Unreleased — 2026-08-03 — Reply Decision Window: Polling + Fit Guard
- The manual-Reply TX window now polls instead of sleeping to the cutoff: a Reply transmits as soon as it is armed (within ~0.1 s) rather than waiting until the decision deadline, and the window stays open
TX_DECISION_CUTOFF_SECONDS(5.0, operator-selected) past slot start. A fit guard enforces the FT8 physical limit — the fixed 12.64 s waveform in a 15 s slot can only start by ~2.4 s in, so a Reply armed past that deadline defers to the next eligible slot instead of overrunning the boundary (an overrun is undecodable at the partner and deafens the next slot’s RX). TX-window decisions (armed/transmitted, deferred past fit, cutoff reached) are logged at DEBUG undermrrc-ft8.tx. Regressions: in-window tap transmits, wrong-parity slot is rejected, no-tap closes silently, past-fit-deadline defers.
Unreleased — 2026-08-03 — Public Host ACL Opened
.envMRRC_FT8_ALLOWED_HOSTSnow includes the public domainradio.vlsc.net(waslocalhost,127.0.0.1). The NFR-035 Host/Origin checks gate WebSocket and every mutation (login, select, reply, CQ, STOP …) — with only loopback names allowed, all public POST/WS traffic was 403 and only GETs worked, so the PWA loaded but could never log in. Adding the domain lets the Caddy edge (radio.vlsc.net:9988, dual-stack) reach the full API over IPv4 or IPv6; the Host header is the domain either way, so no protocol-specific entry is needed..envis operator config (gitignored); the deploy templates already document setting it.
Unreleased — 2026-08-03 — Manual Reply Decision Window + Feed Red Text
- Fixed the manual Reply always missing the slot right after the message it answers: the orchestrator locks a slot’s TX decision at the same instant the previous slot’s decode reaches the UI, so an operator-tapped Reply could never make that slot and always transmitted a full T/R cycle (2 slots) late — by which time the partner had often moved on. The TX decision is now provisional (I9):
TxDriver.on_slot_startkeeps the slot’s window open untildecision_cutoff(2.0 s, the latest a 12.64 s waveform still fits) when the sequencer is idle, re-checks the armed parity at the cutoff, and transmits a fast manual Reply in the current slot. Auto-sequencer transmissions still go out immediately, unchanged. Regressions: in-window tap transmits, wrong-parity slot is rejected, no-tap closes silently. - Band Activity text: self-transmitted echoes (
mine, decodedfrom_call= own call) and stations calling/reply to me (to_me) render red (#ff7b72) instead of the old maroon background alone.
Unreleased — 2026-08-03 — Replay Opposite-TX-Slot Phase (UC-003)
- Implemented the UC-003 “opposite TX slot” the reply path was missing:
TxDrivergated on a hard-codedtx_parity = 0(even slots) that never changed, so a reply to a partner transmitting on the server’s own phase always collided with the partner’s ongoing CQ — the retransmission budget burned into a deaf partner and the QSO silently failed. The phase is now carried by the sequencer (tx_phase, 0 = even / 1 = odd):reply_totakes the parity opposite the slot the partner’s message was decoded from (1 − slot_id % 2, plumbed throughoperation/select’s newslot_idfield from the UI candidate), andTxDriver.on_slot_startgates onsequencer.tx_phaseinstead of a fixed parity. CQ always runs on the default even phase (start_cqresets). A message without a known slot still defaults to even. End-to-end check: a CQ heard in an even slot is answered on odd slots and the full QSO alternates to completion. Regressions: sequencer phase set/reset, driver odd-phase gating, and API slot→phase selection.
Unreleased — 2026-08-03 — UtcRing Eviction-Index Root Cause
- Root cause of the ~90 s aligned↔︎shifted oscillation left open in the previous slice:
UtcRingphysical positions were keyed to(absolute − base) % capacity, so once the ring filled (~60–75 s in) and eviction advancedbase, already-written samples were never moved while reads addressed them with the newbase. Every later slot read content shifted by the cumulative base advance (“D”) — growing 1:1 with wall clock (the +5 s/+10 s lags), wrapping at capacity (the aligned↔︎shifted cycle), and exactly repeating a slot when D hit an integer slot multiple (the BD2LA / morning stale-repeat decodes). Ring metrics stayed green because loss accounting was untouched; any recording probe that never traversed the ring always looked healthy. Positions are now keyed to the absolute sample index alone (X % capacity), so eviction never requires moving data andbaseis purely a bounds/gap-check boundary. Regression:test_ring_slot_content_is_unaligned_after_evictionwrites known-pattern slots across three evictions and requires a fully retained earlier slot to read back byte-exact — constant-value slots had masked the shift.
Unreleased — 2026-08-03 — Isolated Capture Process
- Audio capture moved into a supervised subprocess (
capture_proc.py), mirroring the DSP Worker isolation. Overnight A/B evidence: the server process’s own CoreAudio stream silently degrades ~60 s in (content permanently ~10.5 s stale, decodes die, ring metrics stay green) and in-process stream recreation never heals it, while any fresh process — direct or spawn child — captures the same device indefinitely. The child reusesAudioCaptureverbatim and forwards every converted block as a(seq, epoch, payload)tuple; the parent’s reader thread feeds the unchangedUtcRingand waterfall tap (moving the FFT off the audio callback thread); a watchdog restarts a dead or stalled child with bounded backoff. TheCaptureHealthMonitorrecovery action is now a child-process restart — a guaranteed-fresh capture session — with the AUDIO interlock latch and three-per-episode budget unchanged.
Unreleased — 2026-08-03 — RX Timestamp Root Cause
- Decode list UX rework per operator request: the candidate list is now a chronological feed (WSJT-X main window style) — every message a row, newest slot on top, each 15 s slot headed by a separator carrying the slot UTC and the dial frequency. The dial frequency comes from a new 5 s rig poll feeding
radio.freq_hzin the state snapshot (display-only; poll failures never fault). Re-applied slots replace their rows, so reconnect replay can no longer duplicate entries; stale-row aging is unchanged. - Root cause of the permanent time-shifted sessions: when the audio callback stalls ≥250 ms, CoreAudio delivers the stalled period as backlog; the legacy wall-clock re-anchor stamped that backlog as live audio, shifting every later slot by the backlog length (+6 s/+10.5 s measured) and killing all decodes while ring metrics stayed green. Block epochs are now anchored to CoreAudio’s ADC hardware timestamp (
time_info.inputBufferAdcTime, calibrated per block againstcurrentTime): the timestamp travels with the audio, so a stall becomes one recorded gap (one skipped slot) instead of a permanent shift. The wall-clock sample-count chain remains as the fallback for hosts without ADC timestamps; stalls log an explicit “input stall” warning. - Root cause of the degraded capture sessions found by A/B isolation on the live device: the FT-710 UAC endpoint’s CoreAudio float32 path intermittently delivers toneless noise at band level (rms fine, spectral structure gone), while int16 opens deliver the real band every time — including moments when the float32 server session was already dead. RX capture therefore switched from float32 to int16 with in-seam normalization; the converter contract (float32 → 12 kHz int16, AD-004) is unchanged. The monitor/self-heal from this slice stays as defense in depth.
Unreleased — 2026-08-02 — Capture Self-Healing and Debug Logging
- Stale-display hardening on the cockpit: the waterfall canvas now dims and desaturates while its WebSocket is offline (a frozen canvas used to impersonate a live band), candidate rows age into a dimmed
staleclass after ten minutes on a slow re-render tick, and row time is derived from the slot itself so replayed decode history can no longer re-float old stations to the top as if freshly decoded. - Added RX-chain debug logging (
MRRC_FT8_LOG_LEVEL,restart.shdefaults to DEBUG): per-slot ring state (base/high_water/gaps/dropped/overruns), per-message decode detail, decode-broadcast publish/replay counts, and capture re-anchor/overflow events. - Root-caused the recurring dead-decode incidents with that evidence: a USB capture session can silently degrade mid-run — slot content time-shifted by a constant (~6 s/~10.5 s measured), zero-tailed, or looping a stale buffer — while ring metrics stay perfect and PortAudio reports no overflow. Fresh streams on the same device are always clean; degraded sessions never recover by themselves.
- Added
CaptureHealthMonitor(four consecutive hot-band, zero-decode slots) wired in the composition root: on detection it latches the AUDIO interlock (TX disarms; manual clear/re-arm still required, §12) and automatically reopens the capture stream in monitor state, at most three bounces per episode.AudioCapture.start()now recreates a stopped stream. - Regressions: monitor edge/reset/threshold behavior and stop→start stream recreation.
Unreleased — 2026-08-02 — Live Radio Field Fixes
- Fixed the TX-driver fault funnel: a
TxRefusedraised by the safety controller (STOP-cancelled playback, disarm, watchdog, already-latched interlock) is now only counted, never reported through the error hook into the DSP interlock latch. Previously, the dead-man STOP cancelling an in-flight playback latched a spurious DSP fault and every subsequent Reply/CQ arm was refused until a manual clear. Real CAT/audio faults continue to latch insidetransmit()before it raises; encode-path failures still reportreport_fault(DSP). Added thetest_tx_refused_does_not_report_dsp_faultregression. - Wired
SafetyController.on_eventand the DSP-fault funnel to the composition logger: every arm/TX start/TX stop/PTT-off/watchdog/fault transition is now visible in the server log without an authenticated client. - Hardened
restart.sh: the old server is found by the:8000LISTEN socket andpgrep -f server.main(case-safe), waited out, escalated to SIGKILL, and the audio device is given an 8 s settle before the new server opens it. Measured on the FT-710 UAC device: a process that opens the device while a previous holder is dying (or within ~2 s of its SIGKILL) gets a permanently degraded stream — full-level audio with starved/scrambled content that decodes zero messages for the process lifetime. - Field finding recorded: the server never sets the radio mode; FT-710 must run USB-D (PKTUSB), not USB. In plain USB the CAT PTT keys the mic path and USB TX audio never reaches the modulator, so transmissions key with no RF drive.
Unreleased — 2026-08-01 — Standard FT8 DSP Slices
- Added the stable
wsjt_ft8_encodeC ABI backed by vendorgenft8andgen_ft8wavewith an exact 48 kHz, 606,720-sample float waveform. - Added fresh-build regressions for the waveform contract, standard sent text, deterministic failure-output clearing, rate/capacity validation, every nullable ABI pointer and failed message encoding.
- Registered
dsp/ft8_stdcall.f90, an equivalent extraction of the vendor standard-callsign helper that keeps the headless FT8 source manifest independent of the unrelated Q65 codec chain. - Kept the shared library dynamic export surface restricted to implemented C ABI entry points.
- Added
wsjt_ft8_decode_standardfor exact 12 kHz/180,000-sample int16 slots, returning a fixed 256-result Fortran-owned batch with deterministic result initialization and no Python/C callbacks from decoder code. - Matched the vendor full-slot standard decode orchestration with staged 41/47/50 half-symbol calls on one decoder, preserving depth-2/3 saved-buffer behavior while disabling wall-clock bailout for complete synthetic slots.
- Added fresh-build synthetic CQ coverage plus rate, shape, capacity, ABI-size, every nullable pointer, failure-output clearing and no-partial-copy regressions; the dynamic export surface is now exactly the three implemented ABI entry points.
- Made the native standard-decode ABI fail closed on invalid QSO progress, sensitivity, UTC and frequency-window bounds before mapping sample/results or entering vendor code; added bounds-checked invalid-config coverage and same-process CQ→CQ→empty→CQ state-isolation regression.
- Added
wsjt_ft8_decode_improvedwith profiles 0–4, exact 1–12-way inclusive frequency partitioning, cycles 1–3 and a pure-Fortran OpenMP result batch with stable cross-pass exact deduplication. - Added fresh-build noiseless regressions for every Improved profile, all shared native fail-closed boundaries, Improved profile/thread/cycle limits and a 12-thread minimum-band case.
- Added the initial relocatable LDPC/FFTW include adaptations; the final seven-copy register below supersedes their original include-only scope, and inverse transformations still prove byte identity with immutable vendor sources.
- Recorded the upstream
OMP_STACKSIZE=10Mpre-load runtime contract after a bounds-checked 12-thread regression reproduced stack corruption without it and passed with it. - Made every Improved request reset the six even/odd CQ, MyCall and QSO detection-history structures with the vendor
6000.0frequency sentinel, zero offsets and zero complex symbols; A8 eligibility is now recomputed from AP plus a complete DX call/grid context instead of being permanently disabled. - Removed Improved OpenMP data races with thread-private slot, downsample, FFTW plan and cycle scratch state; subtraction is now deterministic within each assigned band, OSD initialization is fully serialized and exact requested-team failure returns internal status without copying partial results.
- Made A8 ownership deterministic, added an atomic near-Rx suppression gate, and rebuilt all AP masks for every request; clean direct-A8, context-switch and native gate probes cover the behavior while the non-reproducible weak direct-A8 fixture remains a strict documented expected failure.
- Registered all seven exact reversible Improved vendor copies, added repeated parallel-region/multisignal regressions, and kept result append order explicitly unspecified.
- Kept production linkage at exactly four C ABI exports and added a configured ELF version-map regression; the optional direct-A8 test hook is available only in test-hook builds.
- Added frozen/slotted Python DSP request/result metadata and the sole ctypes binding, with pre-load
OMP_STACKSIZE=10M, exact ABI/capability negotiation, strict 12/48 kHz buffer/config validation and immutable native-result copies. - Serialized every ABI query, decode and encode through one global reentrant lock; dependency regressions reserve CDLL access to
binding.pyand binding imports to the supervised Worker boundary. - Hardened the Python boundary to negotiate a zero ABI reserved field, require aligned native buffers plus writeable TX output, discard all failed native decode outputs and validate count/overflow again at the shared adapter boundary.
- Strengthened ctypes layout/signature regressions and exact-path dependency scanning, including standard DLL loader aliases; oversized integers now produce the same
ValueErrorfrequency contract as other invalid encode inputs. - Added exact Protocol v1 UTF-8 JSON control frames with a 64 KiB limit, nine closed frame schemas, deterministic serialization, strict duplicate/type/finite-number validation, fixed shared-memory descriptors, bounded decode results and sanitized errors.
- Added the spawn-only synchronous DSP Worker with pre-import
OMP_STACKSIZE, one explicit/default-path binding, generation-before-open validation, read-only RX/caller-owned TX mappings, metadata-only replies and nonzero exit on protocol/pipe corruption. - Verified real fresh-build Worker ping/shutdown, shared-memory encode and known-CQ decode, observable allocation mismatch recovery, parent-only unlink and error sanitation; recorded Darwin’s unique page-rounded POSIX SHM size without weakening logical 12/48 kHz shapes.
- Resolved I8 with the implemented wire representation and exact logical buffer capacities; supervisor timeout/restart policy and I9 timing measurements remain future slices, so this does not declare M1 complete.
- Added
server/core/supervisor.pyas the sole parent-side owner of the Worker process and control pipe (AD-003, §10.4, §11.1): spawn-only startup with protocol-ping readiness verification, per-request IPC timeouts, generation/request-id correlation with stale-response, unexpected-type and malformed-frame rejection, sanitizedWorkerFaultcodes and one bounded restart per fault with fresh generations and never-reset request ids. - Added synchronous supervisor health reporting (state, generation, restart count, last failure) with a transition callback for engine/audit consumers; the asyncio engine will drive the supervisor through
asyncio.to_thread. - Added spawn-level supervisor regressions with scripted fake Workers covering IPC timeout, mid-request and pre-request crashes, stale/corrupt/unexpected responses, error frames without restart, outgoing-frame validation without touching the Worker, bounded crash-loop degradation, graceful and forced stop, idempotent lifecycle, double-start rejection and monotonic request ids across restarts.
- Added the opt-in I9 benchmark (
MRRC_FT8_I9_BENCHMARK=1): a fresh build, the real supervised Worker and a deterministic five-signal noisy synthetic slot time the production request path across profiles 0–4, thread counts 1–12 and a cycles-3 spot check, writing a JSON artifact and asserting the default profile-3/Auto configuration meets the provisional cutoff. - Resolved I9 on Apple M2 (4P+4E): profile 3 / Auto(7) decodes in 0.226–0.274 s wall with native time within IPC noise; scaling saturates at 4–6 threads and 12-thread oversubscription is slower. Recorded the V1.0 Auto thread policy
clamp(cpu_count - 1, 1, 12)and the provisional TX decision cutoff of slot end + 2.5 s in SDD §9.4, §12.6 and §13.4. - Implemented
server/engine/msgparse.py, the supported standard-message subset ofdecodedtext.cpptoken logic: CQ (with FD/DX modifiers), directed grid, report, R+report and RR73/RRR/73 shapes with hash-callsign and free-text rejection; end-of-message tokens are matched before the colliding grid/report regexes. - Implemented
server/engine/sequencer.py, the one-QSO Tx1–Tx5 state machine distilled frommainwindow.cppauto-sequence rules with contest branches removed: CQ/answerer flows, NFR-055 one-plus-three retransmission budget with progress reset, retry exhaustion that disarms and retains context, anti-QRM auto-stop when the partner calls another station, RR73/73-triggered logging, a single courtesy 73 repeated only on partner repeats, and protocol-clamped (-50..+50) report formatting; SDD §15.6 was made precise about the CQ exemption and completion behavior in the same change. - Added 49 engine regressions covering the parser shapes/edges and both full QSO flows, exhaustion, budget reset, partner-loss, third-station/free-text rejection, RRR and RR73-shortcut branches, report clamping and grid capture.
- Added
server/engine/orchestrator.py, the UTC slot loop (AD-006, §9.2/§9.4): injectable epoch clock and sleeper withfloor(epoch/TRperiod)slot identity, FT8/FT4 periods, boundary decode dispatch through an injected decoder over an exact 360,000-byte slot source, the I9 2.5 s decision cutoff with display-only late batches that are never fed to the sequencer, slot-mismatch/decoder-failure error events that keep the loop alive, and NFR-002 counters (slots, skips, decodes, deadline misses, errors). TX keying is intentionally absent; the sequencer is only fed on-time messages. - Added deterministic fake-clock orchestrator regressions for slot math/parity, boundary dispatch order and start announcements, on-time sequencer feeding with decode SNR, late-result display-only handling, slot mismatch, decoder exception recovery and skipped/oversize slot sources.
- Added
server/engine/audio_rx.py(AD-004): the single streaming 48 kHz float32 → 12 kHz int16 conversion (97-tap FIR anti-alias plus phase-consistent 4:1 decimation, sample-exact across arbitrary block boundaries), a UTC-indexed ring serving exact 180,000-sample slots with explicit gap ranges and overrun/eviction metrics, and the thin sounddevice capture seam with an injectable stream factory. - Added
server/engine/dsp_decode.py(§9.2, §11.2): the production SlotDecoder reusing one parent-owned 360,000-byte segment per supervisor request, mapping slot start toutc_hhmmss, forwarding the plain-str Protocol v1 config throughasyncio.to_threadand converting decode_ok/error frames intoDecodeBatch/DecodeError. - Moved the I9 Auto thread policy into runtime code as
server/core/models.py:auto_thread_count(clamp(cpu_count - 1, 1, 12)); the benchmark imports the single implementation. - Added
server/engine/rig.py(AD-005, §9.3, §11.2): the async rigctld short-command TCP client overasyncio.open_connection, serialized through one lock, with input validation before any I/O,RPRT -nsurfaced asRigError(code), and transparent reconnect on timeout/EOF/garbage replies so the safety layer can re-verify PTT-off after reconnect (§15.3). - Added
server/engine/audio_tx.py(AD-004, §11.2): the bounded 48 kHz float32 TX playback seam.validate_tx_bufferrejects anything but a non-empty 1-D float32 buffer of at most one 606,720-sample waveform before PTT can be keyed;TxPlayerwrites blocks through an injectable stream factory (lazysounddevice.OutputStreamdefault), surfaces device loss asTxAudioError, and offers synchronous idempotentcancel()that aborts a blocked write. - Added
server/engine/safety.py(§15, NFR-050..058), the sole PTT authority: startup is monitor-only with best-effort PTT-off; manualarm()refuses while interlocks are faulted and retries PTT-off first when the line state is uncertain (§15.3);transmit()rechecks interlocks/armed/PTT-uncertainty and the aggregate budget immediately before keying, races playback against an injectable per-transmission deadline (waveform + margin), and always finalizes with best-effort PTT-off;stop_tx()is the idempotent priority STOP (cancel audio, disarm sequencer, non-blocking PTT-off, audit event) andreport_fault/clear_faultimplement the §15.5 fault matrix with manual re-arm only; every TX start/stop/reason emits aSafetyEvent(NFR-070) andhealthfeeds NFR-076. - Added 22 TX-path regressions: block-exact playback, stream contract, cancel of a blocked write, device-loss mapping, concurrency and invalid-buffer rejection; plus the safety fault matrix — startup PTT-off, keying order around audio, unarmed/faulted refusal, idempotent mid-play STOP with sequencer disarm, PTT-on/PTT-off failure handling with reconnect retry, audio device loss, per-transmission and aggregate watchdogs via a controllable sleeper, and pre-PTT buffer rejection.
- Added
server/engine/waterfall.py(§11.2, NFR-004, AD-007):SpectrumComputerturns the UTC-stamped 12 kHz int16 stream into fixed-cadence waterfall lines (Hann window, rFFT, dB-to-uint8 quantization, compactWF01binary frames with round-trip validation); epochs derive from absolute sample index so block boundaries never shift cadence, and UTC gaps reset the line accumulator instead of bridging.SpectrumFanoutis the lossy delivery input with bounded drop-oldest per-subscriber queues and drop counters for NFR-076, so slow waterfall clients cannot delay safety traffic (SC8). - Added 12 waterfall regressions: tone peak bin, silence floor, block-size-independent cadence/epochs, gap reset, binary round-trip and corruption rejection, invalid inputs, drop-oldest/newest-kept fan-out, idempotent close/detach and non-blocking publish under a flooded subscriber.
- Added
server/engine/repository.py(AD-014, §7.5, NFR-071..073): the canonical SQLite store withqso/qso_event/audit_event/decode_event/setting_metatables anduser_versionmigrations. QSOs are recorded from the sequencer’s log record with a completion epoch;void_qsois the audited 30-second undo (§6) that marks void with actor/reason evidence instead of deleting;abort_active_qsosimplements the NFR-058ABORTED_RESTARTtransition;enforce_retentiondeletes only expired decode (7 d) and audit (90 d) rows while QSO data never expires; settings round-trip as JSON. - Added
server/engine/adif.py(NFR-072): ADIF 3.1.4 generation from non-void canonical rows only, with exact<NAME:LEN>field lengths, completion-epoch-derivedQSO_DATE/TIME_ON, MHzFREQ, optional-field omission and non-printable content rejection. - Added
server/web/auth.py(AD-009, NFR-032..039): Argon2id hashing/verification with per-hash salt (newargon2-cffidependency), opaque random runtime-only sessions with 30-minute idle and 12-hour absolute expiry, bounded progressive login delay with reset on success, the five-minute re-authentication window for diagnostics, and pure Host/Origin validation helpers for mutations and WS upgrades. - Added
server/web/lease.py(AD-010, §6, §15.4, NFR-037): the single renewable control lease with 15 s TTL and 5 s heartbeat contract, owner-only renewal, observer refusal, one-shot dead-man callback on TTL expiry and on controller disconnect without waiting for the TTL (wired to priority STOP TX in the composition layer), auditable acquire/release/expire/disconnect events and no lease restoration across restarts. - Added
server/web/api.py(§10.1, §10.3, §11.3): the versioned/api/v1REST surface with the hardenedSecure; HttpOnly; SameSite=Strictsession cookie, Host/Origin validation on every mutation,Idempotency-Keyreplay without side effects andX-Expected-Revisionstale-client rejection. Control rules are enforced as designed: lease-gated select/Reply/CQ/TX-off/band, observer STOP bypassing the lease (NFR-038), re-auth-gated diagnostic tar.gz export that never contains password or cookie secrets (NFR-039/075), schema-validated settings with safety-impacting keys locked during TX, and audited login failures, Reply/CQ, STOP, void and export with session-prefix actors. - Added
server/web/ws.py(§10.2, AD-011): the three bounded streams. State coalesces newest-wins and closes irrecoverably slow clients; decodes stay ordered on a bounded queue with a 32-batch reconnect history and overflow-closed slow clients; waterfall reuses the lossy fan-out. Every upgrade authenticates by cookie plus Host/Origin, and a controller state-stream disconnect drops the lease through the §15.4 dead-man path. - Added the build-step-free landscape PWA under
server/web/static/(AD-011, NFR-060/063): vanilla-JS modules for REST intents with idempotency keys, three reconnecting stream clients, a WF01 canvas waterfall, candidate selection with Reply intent, an always-visible bottom safety bar with unconditional STOP, a portrait degraded observer/STOP view, and a service worker that pre-caches only the app shell and never API/WS traffic.index.htmlcarries no inline JS logic. - Added
server/main.py(§11.3, §12.3): the lifespan composition root.OMP_STACKSIZE=10Mis set before any NumPy/OpenMP import; startup marks interrupted QSOsABORTED_RESTART, starts the safety controller monitor-only with best-effort PTT-off, then starts capture (with the waterfall tap), supervisor/orchestrator, the 1 s lease-expiry watchdog and hourly session-sweep/retention maintenance; shutdown runs priority STOP before audio/rig/worker teardown. Dead-man and lease-event callbacks are scheduled thread-safely onto the lifespan loop. Configuration comes fromMRRC_FT8_*environment variables with fail-startup gaps; Uvicorn binds loopback only. AudioCapturegained an optional converted-streamtapso the waterfall computer shares the single 48→12 kHz conversion, and the state snapshot reports the orchestrator’s NFR-002 counters.- Added the deployment artifacts under
deploy/(§12.1–12.3): the Caddyfile owning 80/443 and proxying to loopback FastAPI, the unprivileged systemd unit (audio/dialout groups,OMP_STACKSIZE=10M, secrets via an uncommitted env file, loopback-only hardening) and the macOS user LaunchAgent. - Added the password bootstrap CLI
python -m server.main --hash-password(§12.6): it prints a verifiable Argon2id hash forMRRC_FT8_PASSWORD_HASHand exits, prompting via getpass when no value is given so the password never lands in shell history. - Fixed the PWA login overlay: author
display: gridrules on.overlay/#cockpitoverrode the UA[hidden] { display: none }rule, so both panels always rendered and the absolutely-positioned waterfall canvas painted over the login form, swallowing its taps (password field untypable). Added an explicit[hidden] { display: none !important }guard, bumped the service-worker shell cache tov4so clients pick up the fix, and pinned the guard with a static-contract regression. - Fixed the waterfall canvas never accumulating history:
getBoundingClientRect()returns fractional CSS pixels, so comparing rawrect.width/heightagainst the integercanvas.width/heightmismatched on every frame and the reassignment cleared the bitmap, leaving only the newest line. Resize now compares floored dimensions; shell cache bumped tov5and a static-contract regression pins the floored comparison. - Fixed the candidate list freezing after the first slots: FT8 messages repeat verbatim every slot, and the client deduped by message text alone, so every later batch was discarded and the list never scrolled. The dedup key is now slot id + text, so each new slot’s decodes prepend; shell cache bumped to
v6with a static-contract regression. - Added seven hardware-free deploy contract tests: textual checks of the Caddyfile/systemd unit/plist (loopback proxy target, unprivileged user, stack size, no committed secrets) plus a real subprocess CLI round-trip verified through
PasswordHasher. - Implemented NFR-002 decode-latency visibility:
server/engine/latency.pyaggregates wall-clock decode duration per (profile, threads) in fixed buckets aligned with the 2.5 s decision cutoff;SupervisorDecoderrecords every successful request,main.pywires one histogram throughAppState, and/healthnow exposesdecode_latencyplus the orchestrator’sdeadline_missescounter. - Added
acceptance/real_radio.py(SC9, §12.2, §15), the hardware-gated FT-710 acceptance runner: preflight (rigctld/PTT-off/audio devices), monitor (tune to the FT8 QRG, capture one exact UTC slot, supervised live decode with latency histogram) and a--tx-gated TX phase (safety-keyed tone with PTT-off verification, mid-TX priority STOP timing). First run: 10 live 40 m FT8 decodes; STOP releases PTT in 0.26–0.31 s (SDD §13.5 I10). - Fixed two defects found by that acceptance. (1)
AudioCapturederived every block’s epoch from one jittery wall-clock read; real CoreAudio callback jitter (±ms) carved one-sample gaps into the UTC ring and invalidated every slot. Epochs are now anchored to the sample count and only re-anchored after a >250 ms stall, andUtcRing.writequantizes to the nearest sample instead of truncating (the ±1-ulp product landed on alternating sides of the integer). (2)TxPlayer.playnow treats a write failure aftercancel()as normal cancellation — real PortAudio fails the blocked write with -9986 whenabort()lands mid-write. - Acceptance environment notes: the desk runs two rigctld instances (FT-710 = hamlib 1049 on
/dev/cu.usbserial-0121DB3A0@ 38400, port 4532; a pre-existing IC-M710 profile instance on port 4531). The FT-710’s receiver audio arrives on the C-Media “USB Audio Device” (sounddevice #4), not on the TI “USB Audio CODEC” pair — verified by WWV on/off correlation. - Deployment wiring caught up with §12.6:
MRRC_FT8_AUDIO_DEVICE(index or name substring),MRRC_FT8_DECODER_PROFILE(0–4, default 3) andMRRC_FT8_DECODER_THREADS(auto= I9 clamp or 1–12) are now parsed with fail-startup validation and flow into the productionDecodeConfig. - Fixed the deployment-found slot-skip defect: the orchestrator read the just-ended slot exactly at the boundary, but real audio lands one block (~85 ms) plus scheduling after its sample time, so every slot read returned None (healthy ring, all slots skipped). Decode dispatch now waits a 0.4 s delivery grace past slot end, keeping ~2 s of cutoff headroom; the acceptance runner already read with a grace, which is why hardware acceptance never saw it.
- Production deployment on the Mac mini (radio.vlsc.net): user LaunchAgents
com.mrrc.rigctld-ft710(rigctld 1049 on 4532) andcom.mrrc.ft8(loopback uvicorn with the fullMRRC_FT8_*environment, audio device by name, profile 3 / Auto threads). Verified under launchd: hardened login, health/safety clean, live supervised decodes with the NFR-002 histogram (p3/t7 max 0.283 s) and zero deadline misses. - Public edge live: root LaunchDaemon
com.caddyserver.caddy(deploy template added,HOME=/var/lib/caddyso the root daemon can write storage). The ISP/router blocks inbound 80/443 (LE HTTP-01 and TLS-ALPN-01 both unreachable over IPv6), so the edge serveshttps://radio.vlsc.net:9988using the operator’s existing acme.sh DNS-01 certificate files, which Caddy hot-reloads on renewal. Verified end-to-end through the proxy: TLS verification, PWA 200, API auth gating and login, health, and a WebSocket 101 upgrade (curl must force HTTP/1.1 — h2 strips the upgrade headers). §12.1’s “Caddy owns 80/443” is relaxed for this deployment to “Caddy owns the public TLS port” (9988)./now redirects to the PWA — the first phone visit hit FastAPI’s bare JSON 404. The first real phone session also exposed a mobile defect:user-select: noneonbodymade the login password field untypable; the no-select rule now lives on#cockpitonly (per-input overrides proved unreliable across mobile browsers) and the service-worker shell cache is versioned (v1 → v3) so mobile clients pick up asset fixes. - Added the production TX path:
dsp_encode.SupervisorEncoder(Protocol v1 encode over one reused TX segment), the slot-parityTxDriver(one sequencer message per eligible slot, encode → gatedsafety.transmit, failures counted and left to the fault matrix), and watchdog-polled QSO log wiring into the canonical store. - Added
CqLoopController: DONE re-arms CQ and resets the idle timer, retry-exhaustion/partner-loss re-arms without resetting, manual/fault disarm, lease loss and the configurable idle timeout (60–3600 s, default 600) stop the loop; loop state rides the state snapshot and every transition is audited. The CQ REST intent accepts{"loop": true}; TX off/STOP stop the loop through the existing paths. - Upgraded the candidate pane to Band Activity rows (UTC/SNR/dt/freq/text, CQ bold, addressed-to-me highlight, late dimmed, double-click Reply through the gated path); decode batches now carry
dt/freq/to_me; shell cache bumped to v8. - Fixed the control UI never enabling: every WebSocket state frame (hello and each broadcast) is now decorated per connection with the subscribing session’s own lease view, since the shared broadcast snapshot always reported
lease.mineas false for the holder. The snapshot’sselectedbecame a{"call", "grid"}object while REST mutation responses keep returning the callsign string, and the CQ LOOP countdown now ticks locally at 1 Hz between snapshots and resynchronizes whenever a new state snapshot arrives. - Hardened the TX/safety edge: a deliberate lease RELEASE now disarms manually (sequencer stop, armed flag dropped, PTT untouched so an in-flight waveform plays out and releases PTT itself) while expiry/disconnect keep the dead-man priority STOP; TX-driver and orchestrator decode errors both
report_fault(DSP), faults latch per interlock untilclear_fault(a dead Worker faults once, not once per slot); STOP reasons map to sequencer semantics (manual/api:<actor>→ MANUAL, everything else → FAULT); and the hourly maintenance loop catches per-tick exceptions so one failed sweep cannot kill it. - Added the fault-recovery channel: the lease-gated, idempotent
POST /api/v1/operation/clear-faultclears every latched interlock (empty body) or one named interlock ({"interlock": "<name>"}, unknown name → 422), audits the scope, and never auto-re-arms. The state bar gained a CLEAR FAULT button (visible while faulted, disabled for observers) that clears all; the shell cache stays at v8. - Fixed silent 409s when an observer tapped a decode row:
operation/selectis lease-gated, and the client swallowed the rejection, so the tap looked dead. A tap on a candidate now acquires a free control lease implicitly through the standard grant path (WSJT-X-style single tap, UC-003; a lease held by another session still rejects), and select/Reply rejections surface through a new cockpit toast instead of failing silently. The login form gained a visually-hidden username field (password-manager/a11y pairing); shell cache bumped to v9 with static-contract regressions. - Fixed the cockpit freezing on stale data after a server restart: sessions are intentionally in-memory (NFR-075), so a restart wipes every cookie, but the client only checked the session at boot — dead streams and 401s left the old snapshot on screen with no way back. REST 401s outside
/session/and a 4401 WebSocket close now both reload into the login view; shell cache bumped to v10 with a static-contract regression.
V1.0 — 2026-08-01 — Approved Design Baseline
- Established the TeamSD 15-chapter MRRC-FT8 design.
- Replaced the early in-process DSP concept with a supervised DSP Worker that owns WSJT-X Improved
ft8varOpenMP state. - Required 12 kHz int16 decoder input, 48 kHz TX, binding global lock and
floor(epoch/TRperiod)timing. - Defined Caddy public TLS, secure-cookie password sessions, Host/Origin validation and no URL token authentication.
- Defined multiple observer sessions, one 5 s heartbeat/15 s TTL control lease and authenticated universal STOP TX.
- Adopted a qFT8-inspired landscape cockpit while retaining WSJT-X interaction semantics.
- Limited V1.0 automation to a human-selected, single standard QSO with three retries, automatic logging and post-completion disarm.
- Defined fail-closed fault/restart behavior, local retention, raw diagnostic export re-authentication, and no third-party telemetry.
- Defined macOS LaunchAgent real-radio and Linux systemd simulated acceptance.
- Migrated
sdd-guardianfrom legacy mrrc_ft710 rules to MRRC-FT8 constraints and added a trace regression test.