Skip to content

refactor(app): collapse the mode-launch recipes - #11

Merged
clorth0 merged 1 commit into
mainfrom
refactor/mode-launch-recipes
Oct 18, 2026
Merged

clorth0 merged 1 commit into
mainfrom
refactor/mode-launch-recipes

Conversation

@clorth0

@clorth0 clorth0 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Summary

Candidate #3 from the architecture review: collapse the 10 mode-launch recipes in app.py into one recipe. A new deep module backend/launch.py (LaunchSpec = data + small callbacks, consumed by a single Launcher.launch(); the seam is the LaunchHost adapter app.py supplies) owns the launch recipe and the finish/toast/status derivation in one place. JobBoard (jobs.py, #7) still owns job state — the host's begin/finish are _begin_job/_finish_job; WorkerLifecycle (worker.py, #1) still owns start/stop/outcome() in each adapter. docs/architecture.md names the new module.

Census note: on fresh main (f8f80cd, app.py 479–711) it is 5 _exit_handler modes + 4 hand-built on_done closures + 1 scan-event closure = 10 finish derivations (the review said 6 _exit_handler uses).

10-mode inventory (identical skeleton vs per-mode ingredients)

Identical for all 10 (already in _begin_job): device gate from mode_spec().uses_device (Cannot start: HackRF not detected.), stop-previous (_stop_all_locked), identity, make -> worker.start() (failure: halt + Could not start <death_label.lower()>), JobBoard.publish, _emit_status. Finish skeleton now lives once in launch.py.

Mode Guard / config applied WorkerLifecycle adapter built Started emit(s) Finish mapping (explicit in spec)
sweep _state["sweep_config"]=cfg in build SweepStreamer(cfg, on_sweep, on_exit) — (status only) exit: stopped silent; died → death toast Sweep stopped unexpectedly. + status
decode _state["decode_config"]=cfg Rtl433Decoder(cfg, on_event, on_exit) — exit (Decoder)
capture geo=_receiver_geotag() pre-launch; _state["capture_config"] IqCapture(cfg, on_progress, on_done, geolocation=geo) capture_started {record} done: toast before capture_done {record, reason} → captures list → status if cleared; completed Capture done: <name> (<MB> MB) info / cancelled warn / died error Capture failed: only if device present
adsb _state["adsb_config"] AdsbReceiver(cfg, on_update, on_exit) — exit (ADS-B)
radio _state["radio_config"] RadioReceiver(cfg, on_audio, on_exit, on_signal) radio_started {sample_rate, freq_mhz, demod} exit (Radio)
scan_radio pre-guard empty freqs → No marked frequencies to scan. (board untouched); no _state write RadioScanner(cfg, on_audio, on_event, on_exit) scan_radio_started {count, sample_rate, demod} exit (Scanner)
replay capture_basename → Invalid capture name; find_capture/exists → Capture not found; centre/rate from catalogue entry (#2) IqReplay(path, centre, sr, on_frame, on_done) replay_started {name, freq_hz, sample_rate} done: replay_done {reason, name} → status if cleared, no toast
iq_play basename/.iq → Invalid capture; not found; demod coerced to fm IqAudioPlayer(path, sr, demod, offset, on_audio, on_done) radio_started then iq_play_started done: iq_play_done → status if cleared
decode_file basename/.iq → Invalid capture; not found IqFileDecoder(path, sr, centre, on_event, on_done) decode_file_started + toast Decoding <name> (this can take a moment) done: decode_file_done → completed-only toast Decode finished: <name> → status if cleared
scan _state["scan_config"] AutoScanner(cfg, on_event) — (worker emits scan_started via event) scan: forwards all events; terminal scan_completed/scan_failed finish job → status if cleared

Error paths (invalid name/not found/bad demod/empty scan) stay in the thin _start_* entry points before a spec is launched — unchanged text and order.

Design

  • LaunchSpec(kind, build(ctx), finish, started) — build receives LaunchContext (the identity-wired on_exit/on_done/on_event to pass to the worker constructor), finish is a FinishSpec (exit_finish() / done_finish(event, payload, toast_for, toast_before_event, refresh_captures) / scan_finish()), started returns ordered ("emit", …) / ("toast", …) actions.
  • Launcher.launch() is the only path: host begin_job → started actions. All 10 _start_* in app.py are now thin spec builders (_launcher.launch( ×10, asserted by test; no _exit_handler, no hand-built on_done/on_scan_event closures remain; dead _emit_scan wrapper removed).
  • Genuinely different finishes are not flattened: capture's record payload + captures refresh + toast-before-event, decode_file's completed-only toast, and scan's terminal events are explicit finish data in each spec.

Behaviour parity

Socket event names, status/toast copy, payloads, ordering, and job outcomes are byte-identical, pinned by tests/test_mode_launch.py (fake workers + socket recorder across all 10 modes: publish/start, started payloads, exit/done/scan finish, guards). Characterization tests were written and passed against the pre-refactor code first (6/6), then the single-recipe tests failed first (no backend.launch, 0 launches), then passed.

Tests + mutant

  • New: tests/test_mode_launch.py — 9 tests (above + a test-only mode spec launching through the same path with no new recipe code, and a shared done-derivation across two modes).
  • Mutant demonstrated & restored: swapping completed↔died in the shared _finish_done derivation reddened test_done_modes_finish_derivation + test_shared_done_finish_derivation_across_modes (capture toast/payload + shared modes) — 7/9; after restore 9/9.
  • Local CI-way run (pre-PR): every tests/test_*.py via uv run python passed, check_html_templates.py clean, check_architecture_names.py clean, node --check on all frontend JS clean.

Self-review (two axes)

@clorth0
clorth0 merged commit a673e07 into main Oct 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant