pwn 0.5.621 → 0.5.626

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/README.md +7 -1
  5. data/bin/pwn_gqrx_scanner +78 -9
  6. data/documentation/Agent-Tool-Registry.md +3 -3
  7. data/documentation/Configuration.md +301 -47
  8. data/documentation/Cron.md +5 -3
  9. data/documentation/Diagrams.md +1 -1
  10. data/documentation/Extrospection.md +472 -26
  11. data/documentation/FFI.md +72 -8
  12. data/documentation/Home.md +4 -4
  13. data/documentation/How-PWN-Works.md +7 -3
  14. data/documentation/Mistakes.md +9 -0
  15. data/documentation/SDR.md +83 -10
  16. data/documentation/Skills-Memory-Learning.md +101 -11
  17. data/documentation/diagrams/agent-tool-registry.svg +56 -55
  18. data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
  19. data/documentation/diagrams/aws-cloud-security.svg +25 -25
  20. data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
  21. data/documentation/diagrams/code-scanning-sast.svg +25 -25
  22. data/documentation/diagrams/cron-scheduling.svg +23 -23
  23. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  24. data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -6
  25. data/documentation/diagrams/dot/hardware-hacking.dot +3 -1
  26. data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -2
  27. data/documentation/diagrams/dot/mistakes-negative-feedback.dot +4 -2
  28. data/documentation/diagrams/dot/overall-pwn-architecture.dot +3 -3
  29. data/documentation/diagrams/dot/persistence-filesystem.dot +3 -3
  30. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
  31. data/documentation/diagrams/dot/sdr-radio-flow.dot +39 -16
  32. data/documentation/diagrams/driver-framework.svg +13 -13
  33. data/documentation/diagrams/extrospection-world-awareness.svg +383 -177
  34. data/documentation/diagrams/fuzzing-workflow.svg +24 -24
  35. data/documentation/diagrams/hardware-hacking.svg +84 -69
  36. data/documentation/diagrams/history-to-drivers.svg +18 -18
  37. data/documentation/diagrams/memory-skills-detailed.svg +137 -120
  38. data/documentation/diagrams/mistakes-negative-feedback.svg +152 -137
  39. data/documentation/diagrams/network-infra-testing.svg +27 -27
  40. data/documentation/diagrams/overall-pwn-architecture.svg +97 -97
  41. data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
  42. data/documentation/diagrams/persistence-filesystem.svg +100 -94
  43. data/documentation/diagrams/plugin-ecosystem.svg +35 -35
  44. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +254 -207
  45. data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
  46. data/documentation/diagrams/reporting-pipeline.svg +18 -18
  47. data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
  48. data/documentation/diagrams/sdr-radio-flow.svg +223 -91
  49. data/documentation/diagrams/sessions-cron-automation.svg +18 -18
  50. data/documentation/diagrams/swarm-multi-agent.svg +39 -39
  51. data/documentation/diagrams/web-application-testing.svg +26 -26
  52. data/documentation/diagrams/zero-day-research-flow.svg +25 -25
  53. data/documentation/pwn-ai-Agent.md +16 -10
  54. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  55. data/lib/pwn/ai/agent/assembly.rb +1 -1
  56. data/lib/pwn/ai/agent/btc.rb +1 -1
  57. data/lib/pwn/ai/agent/burp_suite.rb +1 -1
  58. data/lib/pwn/ai/agent/extrospection.rb +2562 -57
  59. data/lib/pwn/ai/agent/gqrx.rb +2 -2
  60. data/lib/pwn/ai/agent/hacker_one.rb +1 -1
  61. data/lib/pwn/ai/agent/learning.rb +16 -16
  62. data/lib/pwn/ai/agent/loop.rb +1 -1
  63. data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
  64. data/lib/pwn/ai/agent/sast.rb +1 -1
  65. data/lib/pwn/ai/agent/tools/extrospection.rb +311 -8
  66. data/lib/pwn/ai/agent/tools/learning.rb +7 -7
  67. data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
  68. data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
  69. data/lib/pwn/ai/agent.rb +1 -1
  70. data/lib/pwn/ai/open_ai.rb +10 -6
  71. data/lib/pwn/config.rb +5 -4
  72. data/lib/pwn/ffi/adalm_pluto.rb +498 -0
  73. data/lib/pwn/ffi/fftw.rb +192 -0
  74. data/lib/pwn/ffi/hack_rf.rb +193 -0
  75. data/lib/pwn/ffi/liquid.rb +244 -0
  76. data/lib/pwn/ffi/rtl_sdr.rb +210 -0
  77. data/lib/pwn/ffi/soapy_sdr.rb +177 -0
  78. data/lib/pwn/ffi/stdio.rb +9 -1
  79. data/lib/pwn/ffi/volk.rb +241 -0
  80. data/lib/pwn/ffi.rb +45 -2
  81. data/lib/pwn/plugins/burp_suite.rb +2 -2
  82. data/lib/pwn/sast/pom_version.rb +2 -2
  83. data/lib/pwn/sast/test_case_engine.rb +1 -1
  84. data/lib/pwn/sdr/decoder/adsb.rb +201 -12
  85. data/lib/pwn/sdr/decoder/apt.rb +19 -5
  86. data/lib/pwn/sdr/decoder/base.rb +314 -1
  87. data/lib/pwn/sdr/decoder/bluetooth.rb +91 -18
  88. data/lib/pwn/sdr/decoder/dect.rb +81 -23
  89. data/lib/pwn/sdr/decoder/dsp.rb +229 -15
  90. data/lib/pwn/sdr/decoder/flex.rb +23 -6
  91. data/lib/pwn/sdr/decoder/gps.rb +80 -18
  92. data/lib/pwn/sdr/decoder/gsm.rb +83 -28
  93. data/lib/pwn/sdr/decoder/iridium.rb +80 -18
  94. data/lib/pwn/sdr/decoder/lora.rb +84 -28
  95. data/lib/pwn/sdr/decoder/lte.rb +80 -18
  96. data/lib/pwn/sdr/decoder/morse.rb +23 -6
  97. data/lib/pwn/sdr/decoder/p25.rb +90 -31
  98. data/lib/pwn/sdr/decoder/pager.rb +23 -6
  99. data/lib/pwn/sdr/decoder/pocsag.rb +23 -6
  100. data/lib/pwn/sdr/decoder/rds.rb +219 -52
  101. data/lib/pwn/sdr/decoder/rfid.rb +81 -27
  102. data/lib/pwn/sdr/decoder/rtl433.rb +85 -27
  103. data/lib/pwn/sdr/decoder/rtty.rb +23 -6
  104. data/lib/pwn/sdr/decoder/wifi.rb +86 -32
  105. data/lib/pwn/sdr/decoder/zigbee.rb +88 -35
  106. data/lib/pwn/sdr/decoder.rb +6 -5
  107. data/lib/pwn/sdr/frequency_allocation.rb +124 -81
  108. data/lib/pwn/sdr/gqrx.rb +1513 -165
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/lib/pwn/ai/agent/extrospection_spec.rb +96 -0
  111. data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
  112. data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +8 -24
  113. data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +37 -0
  114. data/spec/lib/pwn/ffi/fftw_spec.rb +26 -0
  115. data/spec/lib/pwn/ffi/hack_rf_spec.rb +25 -0
  116. data/spec/lib/pwn/ffi/liquid_spec.rb +42 -0
  117. data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +24 -0
  118. data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +25 -0
  119. data/spec/lib/pwn/ffi/volk_spec.rb +36 -0
  120. data/spec/lib/pwn/sdr/decoder/base_iq_spec.rb +65 -0
  121. data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +50 -4
  122. data/spec/lib/pwn/sdr/decoder/rds_spec.rb +45 -4
  123. data/third_party/pwn_rdoc.jsonl +279 -30
  124. metadata +21 -5
data/lib/pwn/sdr/gqrx.rb CHANGED
@@ -244,10 +244,13 @@ module PWN
244
244
  # Sleep a tiny bit to allow strength_db values to fluctuate
245
245
  sleep 0.0001
246
246
  end
247
- # Uncomment for debugging strength measurement attempts
248
- # which translates to speed and accuracy refinement
249
- puts "\tStrength Measurement Attempts: #{attempts} | Freq: #{freq} | Phase: #{phase}"
250
- puts "\tUnique Samples: #{unique_samples} | dbFS Distance Unique Samples: #{distance_between_unique_samples}"
247
+ # Debug strength measurement attempts only when explicitly requested
248
+ # (PWN_GQRX_DEBUG=1). Always-on logging made scans unreadable and hid
249
+ # the real detection summary under thousands of probe lines.
250
+ if ENV['PWN_GQRX_DEBUG']
251
+ puts "\tStrength Measurement Attempts: #{attempts} | Freq: #{freq} | Phase: #{phase}"
252
+ puts "\tUnique Samples: #{unique_samples} | dbFS Distance Unique Samples: #{distance_between_unique_samples}"
253
+ end
251
254
 
252
255
  strength_db.round(1)
253
256
  rescue StandardError => e
@@ -299,6 +302,10 @@ module PWN
299
302
  precision = opts[:precision]
300
303
  step_hz = opts[:step_hz]
301
304
  strength_lock = opts[:strength_lock]
305
+ # Optional hard bounds (used by refine_detections to keep the walk
306
+ # inside the FFT-seeded window so we cannot bleed into a neighbour).
307
+ min_hz = opts[:min_hz]
308
+ max_hz = opts[:max_hz]
302
309
  left_candidate_signals = []
303
310
  right_candidate_signals = []
304
311
  candidate_signals = []
@@ -307,6 +314,8 @@ module PWN
307
314
  strength_db = 99.9
308
315
  puts '*** Edge Detection: Locating Beginning of Signal...'
309
316
  while strength_db >= strength_lock
317
+ break if min_hz && hz < min_hz
318
+
310
319
  tune_to(gqrx_sock: gqrx_sock, hz: hz)
311
320
  strength_db = measure_signal_strength(
312
321
  gqrx_sock: gqrx_sock,
@@ -334,6 +343,8 @@ module PWN
334
343
  strength_db = 99.9
335
344
  puts '*** Edge Detection: Locating End of Signal...'
336
345
  while strength_db >= strength_lock
346
+ break if max_hz && hz > max_hz
347
+
337
348
  tune_to(gqrx_sock: gqrx_sock, hz: hz)
338
349
  strength_db = measure_signal_strength(
339
350
  gqrx_sock: gqrx_sock,
@@ -394,10 +405,15 @@ module PWN
394
405
  signals_detected = opts[:signals_detected]
395
406
  timestamp_start = opts[:timestamp_start]
396
407
  scan_log = opts[:scan_log]
408
+ # Optional top-level metadata merged into the scan response so that
409
+ # #scan_range and #fast_scan_range produce an identical JSON schema
410
+ # (sample_rate_used / nfft / precision / plan_bw_hz / method / …).
411
+ meta = opts[:meta] || {}
412
+ meta = meta.transform_keys(&:to_sym) if meta.respond_to?(:transform_keys)
397
413
 
398
414
  existing_scan_resp = JSON.parse(File.read(scan_log), symbolize_names: true) if File.exist?(scan_log)
399
415
 
400
- # BUG: iteration_metrics not being properly initialized from existing log
416
+ # Prefer caller-supplied iteration_metrics; fall back to prior log.
401
417
  iteration_metrics = opts[:iteration_metrics]
402
418
  iteration_metrics ||= existing_scan_resp[:iteration_metrics] if existing_scan_resp.is_a?(Hash) && existing_scan_resp.key?(:iteration_metrics) && existing_scan_resp[:iteration_metrics].is_a?(Array) && !existing_scan_resp[:iteration_metrics].empty?
403
419
  iteration_metrics ||= []
@@ -409,15 +425,33 @@ module PWN
409
425
  timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
410
426
  duration = duration_between(timestamp_start: timestamp_start, timestamp_end: timestamp_end)
411
427
 
412
- # TODO: Implement iteration_metrics logging per range iteration
428
+ # Canonical top-level schema shared by iterative + FFT scan modes.
429
+ # FFT-only keys (sample_rate_used / nfft) are nil for #scan_range;
430
+ # callers override via :meta. Preserve any previously-logged meta
431
+ # that the current call did not re-supply so mid-scan re-logs do
432
+ # not strip provenance fields.
433
+ prior_meta = {}
434
+ if existing_scan_resp.is_a?(Hash)
435
+ %i[sample_rate_used nfft precision plan_bw_hz demodulator_mode bandwidth squelch strength_lock audio_gain_db rf_gain intermediate_gain baseband_gain decoder method spectrums].each do |k|
436
+ prior_meta[k] = existing_scan_resp[k] if existing_scan_resp.key?(k)
437
+ end
438
+ end
439
+
413
440
  scan_resp = {
414
441
  signals: signals,
415
442
  total: signals.length,
416
443
  timestamp_start: timestamp_start,
417
444
  timestamp_end: timestamp_end,
418
445
  duration: duration,
419
- iteration_metrics: iteration_metrics
446
+ iteration_metrics: iteration_metrics,
447
+ sample_rate_used: nil,
448
+ nfft: nil,
449
+ precision: nil,
450
+ plan_bw_hz: nil,
451
+ method: nil
420
452
  }
453
+ scan_resp.merge!(prior_meta)
454
+ scan_resp.merge!(meta)
421
455
 
422
456
  File.write(
423
457
  scan_log,
@@ -545,10 +579,483 @@ module PWN
545
579
  end
546
580
 
547
581
  # Supported Method Parameters::
548
- # gqrx_sock = PWN::SDR::GQRX.connect(
549
- # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
550
- # port: 'optional - GQRX target port (defaults to 7356)'
582
+ # refined = PWN::SDR::GQRX.refine_detections(
583
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
584
+ # detections: 'required - Array of preliminary signal hashes (from FFT or prior scan)',
585
+ # precision: 'required - Frequency step precision (raster digits)',
586
+ # step_hz: 'required - Frequency step in Hz for edge / peak walking',
587
+ # strength_lock: 'required - Strength lock in dBFS used as the edge gate',
588
+ # plan_bw_hz: 'optional - Expected occupied bandwidth (Hz); bounds the search window',
589
+ # demodulator_mode: 'optional - Mode attributed to detections (defaults to existing / :WFM)',
590
+ # bandwidth: 'optional - Passband attributed to detections',
591
+ # squelch: 'optional - Squelch attributed to detections'
551
592
  # )
593
+ #
594
+ # Post-process a *preliminary* detection list by re-walking each candidate with
595
+ # the traditional S-meter edge_detection + find_best_peak pipeline, restricted
596
+ # to a tight window around the candidate. Purpose: turn an FFT bin estimate
597
+ # into the exact channel frequency so decoding (and analyze_scan retunes) land
598
+ # dead-centre. Failures fall back to the original detection (best-effort).
599
+
600
+ # Supported Method Parameters::
601
+ # geo = PWN::SDR::GQRX.fft_plan_geometry(
602
+ # plan_bw_hz: 'required - expected channel / plan bandwidth (Hz)',
603
+ # step_hz: 'required - band-plan raster 10**(precision-1) (Hz)',
604
+ # res_hz: 'optional - FFT bin width sample_rate/nfft (Hz); used for bin counts',
605
+ # measured_bw_hz: 'optional - measured occupied BW for merge tolerance'
606
+ # )
607
+ #
608
+ # Pure geometry for --fft-scan derived from the two band-plan invariants
609
+ # (plan_bw_hz, step_hz) plus optional spectrum resolution. Every residual
610
+ # clamp that previously hard-coded FLEX/NBFM-scale kHz values is a pure
611
+ # function of those three inputs so the same detector works for all 84
612
+ # band plans (CW 150 Hz → Wi-Fi MHz).
613
+ private_class_method def self.fft_plan_geometry(opts = {})
614
+ plan_bw_hz = opts[:plan_bw_hz].to_f
615
+ step_hz = opts[:step_hz].to_f
616
+ res_hz = opts[:res_hz].to_f
617
+ measured_bw_hz = opts[:measured_bw_hz].to_f
618
+
619
+ plan_bw_hz = 0.0 if plan_bw_hz.negative?
620
+ step_hz = 1.0 if step_hz <= 0.0
621
+ res_hz = 0.0 if res_hz.negative?
622
+ measured_bw_hz = 0.0 if measured_bw_hz.negative?
623
+
624
+ # Peak min-distance in Hz: half-channel, raster, or a small safety floor
625
+ # so wide carriers (fm_radio 200 kHz) don't emit N peaks, and dense
626
+ # narrow ones (FLEX 1 kHz / CW 100 Hz) still separate. Floor is
627
+ # step-aware so CW/RTTY aren't forced to 3 kHz.
628
+ # Floor for peak min-distance. Wide plans must NOT floor at step_hz
629
+ # itself — for fm_radio step=100 kHz that made min_dist ~100 kHz and
630
+ # let strong carriers dominate weaker neighbours within one channel.
631
+ # Prefer a modest absolute floor scaled by plan family.
632
+ if plan_bw_hz.positive? && plan_bw_hz >= 50_000.0
633
+ floor_sep = [step_hz * 0.45, plan_bw_hz * 0.25, 25_000.0].max
634
+ elsif plan_bw_hz.positive? && plan_bw_hz < 1_000.0
635
+ floor_sep = [plan_bw_hz, step_hz, 100.0].max
636
+ elsif plan_bw_hz.positive? && plan_bw_hz < 10_000.0
637
+ floor_sep = [plan_bw_hz * 0.5, step_hz, 500.0].max
638
+ else
639
+ floor_sep = [step_hz, 3_000.0].max
640
+ floor_sep = [floor_sep, 3_000.0].min if plan_bw_hz.positive? && plan_bw_hz < 3_000.0
641
+ end
642
+
643
+ # Wide broadcast: keep sep slightly under one channel so adjacent
644
+ # stations (US FM every 200 kHz) always survive, while pilot/RDS
645
+ # (~19/57 kHz offsets) still get merged under the main carrier.
646
+ sep_hz = if plan_bw_hz.positive? && plan_bw_hz >= 50_000.0
647
+ [step_hz * 0.55, plan_bw_hz * 0.30, floor_sep].max
648
+ elsif plan_bw_hz.positive?
649
+ [plan_bw_hz * 0.5, step_hz, floor_sep].max
650
+ else
651
+ [step_hz, 6_000.0].max
652
+ end
653
+
654
+ min_dist_bins = if res_hz.positive?
655
+ [3, (sep_hz / res_hz).ceil].max
656
+ else
657
+ 3
658
+ end
659
+
660
+ max_half_bins = if res_hz.positive?
661
+ cap_hz = plan_bw_hz.positive? ? plan_bw_hz : 50_000.0
662
+ [(cap_hz / res_hz).ceil, 2].max
663
+ else
664
+ 2
665
+ end
666
+
667
+ # Refine local window half-width.
668
+ # Goal: cover the channel footprint of the *seed* without enclosing the
669
+ # next co-channel neighbour. Using 2*step blew up on fm_radio
670
+ # (step=100 kHz → ±200 kHz, neighbour spacing = 200 kHz). Rule:
671
+ # half ≈ max(0.6·plan, min(2·step, plan), floor)
672
+ # so FLEX stays ~12 kHz and FM stays ~120 kHz (next station at ±200).
673
+ if plan_bw_hz <= 0.0
674
+ base_half = 5_000.0
675
+ lo_clamp = 5_000.0
676
+ hi_clamp = 15_000.0
677
+ elsif plan_bw_hz < 1_000.0
678
+ # CW / RTTY — keep tiny
679
+ base_half = [plan_bw_hz * 4.0, 2.0 * step_hz, 500.0].max
680
+ lo_clamp = [2.0 * step_hz, 200.0].max
681
+ hi_clamp = [plan_bw_hz * 20.0, 5_000.0].max
682
+ else
683
+ # Prefer ~0.6 plan_bw (main-lobe FO) and never use 2*step when
684
+ # step itself is already a significant fraction of plan_bw
685
+ # (fm_radio: step=100k, plan=200k → 2*step == plan → neighbour steal).
686
+ step_term = [step_hz, 0.5 * plan_bw_hz].min
687
+ base_half = [0.6 * plan_bw_hz, step_term, 2_000.0].max
688
+ lo_clamp = [[step_hz * 0.5, 1_000.0].max, 1_000.0].max
689
+ # Cap at 0.75 plan so the next neighbour (typically ≥ plan apart)
690
+ # stays outside the refine window.
691
+ hi_clamp = [0.75 * plan_bw_hz, step_term, 50_000.0].max
692
+ end
693
+ refine_half_hz = base_half.clamp(lo_clamp, hi_clamp).to_i
694
+ refine_half_hz = 1 if refine_half_hz < 1
695
+
696
+ # Narrow fallback bin search around seed when no local FFT peaks clear
697
+ # the SNR gate — half plan or step (capped by refine window).
698
+ narrow_half_hz = if plan_bw_hz.positive?
699
+ [(plan_bw_hz / 2.0), step_hz, [step_hz, 500.0].max].max
700
+ else
701
+ [step_hz, 2_000.0].max
702
+ end
703
+ narrow_half_hz = [narrow_half_hz, refine_half_hz.to_f].min.to_i
704
+ narrow_half_hz = 1 if narrow_half_hz < 1
705
+
706
+ # Snap reject: max walk from the panoramic seed we still accept as
707
+ # "the same channel". Half-plan (or one raster) — for FLEX ~10 kHz,
708
+ # for FM ~100 kHz. Never open the door to the next neighbour.
709
+ if plan_bw_hz.positive?
710
+ max_snap_delta_hz = [
711
+ plan_bw_hz * 0.5,
712
+ step_hz,
713
+ [plan_bw_hz * 0.25, 1_000.0].max
714
+ ].max
715
+ else
716
+ max_snap_delta_hz = [step_hz, 10_000.0].max
717
+ end
718
+ max_snap_delta_hz = max_snap_delta_hz.to_i
719
+ max_snap_delta_hz = step_hz.to_i if max_snap_delta_hz < 1
720
+
721
+ # Cross-chunk / post-refine merge tolerance.
722
+ # Wide broadcast carriers (fm_radio): half-plan (100 kHz) would collapse
723
+ # two legitimate adjacent stations. Prefer a fraction of the *raster*
724
+ # so two seeds on neighbouring 100 kHz channels survive, while
725
+ # subcarrier/pilot debris around a single station still merges.
726
+ if plan_bw_hz >= 50_000.0
727
+ merge_tol_hz = [
728
+ step_hz * 0.55,
729
+ (measured_bw_hz / 2.0),
730
+ (res_hz.positive? ? (2.0 * res_hz).ceil : 0.0),
731
+ 1_000.0
732
+ ].max.to_i
733
+ else
734
+ merge_tol_hz = [
735
+ (plan_bw_hz / 2.0),
736
+ step_hz,
737
+ (measured_bw_hz / 2.0),
738
+ (res_hz.positive? ? (2.0 * res_hz).ceil : 0.0)
739
+ ].max.to_i
740
+ end
741
+ merge_tol_hz = step_hz.to_i if merge_tol_hz < 1 && plan_bw_hz < 50_000.0
742
+ merge_tol_hz = 1 if merge_tol_hz < 1
743
+
744
+ # Local high-res FFT zoom sample rate for refine: ~4–8× plan BW with
745
+ # bounds that still resolve narrow plans and don't waste time on wide
746
+ # ones. Min bound scales with plan so CW isn't forced to 200 kHz SR
747
+ # (though 200 kHz still fine if caller wants speed consistency).
748
+ local_sr = if plan_bw_hz.positive?
749
+ raw = [plan_bw_hz * 8.0, plan_bw_hz * 4.0, 4.0 * step_hz * 64].max
750
+ # Keep a practical floor for GQRX IQRECORD usefulness.
751
+ lo_sr = if plan_bw_hz < 1_000.0
752
+ 50_000
753
+ elsif plan_bw_hz < 50_000.0
754
+ 200_000
755
+ else
756
+ 500_000
757
+ end
758
+ hi_sr = if plan_bw_hz >= 200_000.0
759
+ 2_000_000
760
+ else
761
+ 1_000_000
762
+ end
763
+ raw.clamp(lo_sr, hi_sr).to_i
764
+ else
765
+ 200_000
766
+ end
767
+
768
+ # Window must fit inside the local FFT span (± local_sr/2), else snap
769
+ # validation accepts peaks the zoom never actually captured.
770
+ max_win = [(local_sr * 0.45).to_i, step_hz.to_i, 1].max
771
+ refine_half_hz = [refine_half_hz, max_win].min
772
+ narrow_half_hz = [narrow_half_hz, refine_half_hz].min
773
+
774
+ {
775
+ plan_bw_hz: plan_bw_hz.to_i,
776
+ step_hz: step_hz.to_i,
777
+ res_hz: res_hz,
778
+ sep_hz: sep_hz,
779
+ min_dist_bins: min_dist_bins,
780
+ max_half_bins: max_half_bins,
781
+ refine_half_hz: refine_half_hz,
782
+ narrow_half_hz: narrow_half_hz,
783
+ max_snap_delta_hz: max_snap_delta_hz,
784
+ merge_tol_hz: merge_tol_hz,
785
+ local_sr: local_sr
786
+ }
787
+ rescue StandardError => e
788
+ raise e
789
+ end
790
+
791
+ private_class_method def self.refine_detections(opts = {})
792
+ gqrx_sock = opts[:gqrx_sock]
793
+ detections = opts[:detections] || []
794
+ precision = opts[:precision].to_i
795
+ step_hz = opts[:step_hz].to_i
796
+ strength_lock = opts[:strength_lock].to_f
797
+ plan_bw_hz = (opts[:plan_bw_hz] || step_hz).to_i
798
+ plan_bw_hz = step_hz if plan_bw_hz <= 0
799
+ step_hz = 1 if step_hz <= 0
800
+ mode = opts[:refine_mode].to_s.downcase
801
+ mode = 'lock' if mode.empty? || mode == 'true' || mode == 'default'
802
+
803
+ return detections if detections.empty?
804
+
805
+ # Plan-parametric geometry (half_win / local_sr / snap reject / merge)
806
+ # derived once from (plan_bw_hz, step_hz) so every band plan shares
807
+ # the same formulas — residual FLEX-tuned 5–15 kHz clamps are gone.
808
+ geo = fft_plan_geometry(plan_bw_hz: plan_bw_hz, step_hz: step_hz)
809
+ half_win = geo[:refine_half_hz]
810
+ local_sr = geo[:local_sr]
811
+ narrow_half = geo[:narrow_half_hz]
812
+ max_delta = geo[:max_snap_delta_hz]
813
+ local_nfft = 2048
814
+ local_res = local_sr / local_nfft.to_f
815
+ # How many raster bins either side of the seed to lock-hunt.
816
+ # Wide FM (step 100 kHz): ±1 is enough to fix a bad snap; narrow plans
817
+ # hunt ±2 so bursty carriers still find a strong centre.
818
+ hunt_steps = plan_bw_hz >= 50_000.0 ? 1 : 2
819
+
820
+ puts '-' * 86
821
+ puts "[REFINE] #{mode}-mode channel lock of #{detections.length} preliminary detection(s)"
822
+ puts " raster=#{PWN::SDR.hz_to_s(freq: step_hz)} Hz plan_bw=#{PWN::SDR.hz_to_s(freq: plan_bw_hz)} Hz strength_lock=#{strength_lock} dBFS"
823
+ puts " geo: half_win=#{PWN::SDR.hz_to_s(freq: half_win)} local_sr=#{local_sr} narrow_half=#{PWN::SDR.hz_to_s(freq: narrow_half)} max_snap_Δ=#{PWN::SDR.hz_to_s(freq: max_delta)} hunt=±#{hunt_steps}"
824
+ puts '-' * 86
825
+
826
+ mode_str = (opts[:demodulator_mode] || :FM).to_s.upcase
827
+ passband_hz = plan_bw_hz.positive? ? plan_bw_hz : 20_000
828
+
829
+ # Band-plan IF once up-front for S-meter/lock-hunt (meaningful STRENGTH).
830
+ begin
831
+ cmd(gqrx_sock: gqrx_sock, cmd: "M #{mode_str} #{passband_hz}", resp_ok: 'RPRT 0')
832
+ cmd(gqrx_sock: gqrx_sock, cmd: 'L SQL -150.0', resp_ok: 'RPRT 0')
833
+ sleep 0.08
834
+ rescue StandardError => e
835
+ puts "[REFINE] WARNING: could not apply band-plan IF (#{e.class}: #{e.message})"
836
+ end
837
+
838
+ # Fast S-meter sample helper (median of N reads after a short settle).
839
+ sample_smeter = lambda do |hz, n: 4, settle: 0.04|
840
+ tune_to(gqrx_sock: gqrx_sock, hz: hz)
841
+ sleep settle
842
+ samples = n.times.map do
843
+ s = cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
844
+ sleep 0.008
845
+ s
846
+ end
847
+ samples.sort!
848
+ samples[samples.length / 2]
849
+ end
850
+
851
+ refined = []
852
+ detections.each_with_index do |det, idx|
853
+ seed_hz = (det[:raw_peak_hz] || det[:hz] || PWN::SDR.hz_to_i(freq: det[:freq])).to_i
854
+ if seed_hz.zero?
855
+ puts " [REFINE] detection ##{idx + 1} has no usable seed frequency — keeping as-is"
856
+ refined << det
857
+ next
858
+ end
859
+
860
+ seed_snap = ((seed_hz.to_f / step_hz).round * step_hz).to_i
861
+ win_lo = seed_hz - half_win
862
+ win_hi = seed_hz + half_win
863
+ puts "\n[REFINE #{idx + 1}/#{detections.length}] seed=#{PWN::SDR.hz_to_s(freq: seed_hz)} snap=#{PWN::SDR.hz_to_s(freq: seed_snap)} window=#{PWN::SDR.hz_to_s(freq: win_lo)}..#{PWN::SDR.hz_to_s(freq: win_hi)} mode=#{mode}"
864
+
865
+ peak_hz = nil
866
+ peak_pwr = nil
867
+ peak_bw = det[:bw_hz].to_i
868
+ peak_snr = det[:snr_db]
869
+ peak_nf = det[:noise_floor_db]
870
+ smeter = nil
871
+
872
+ if mode == 'fft'
873
+ # Legacy path: local high-res FFT zoom (slow — one IQRECORD + FFT per hit).
874
+ begin
875
+ cmd(gqrx_sock: gqrx_sock, cmd: "M RAW #{local_sr}", resp_ok: 'RPRT 0')
876
+ tune_to(gqrx_sock: gqrx_sock, hz: seed_hz)
877
+ sleep 0.08
878
+ snap = get_spectrum_snapshot(
879
+ gqrx_sock: gqrx_sock,
880
+ center_freq: seed_hz,
881
+ sample_rate: local_sr,
882
+ nfft: local_nfft,
883
+ avg: 4,
884
+ capture_secs: 0.08,
885
+ channel_bw_hz: plan_bw_hz,
886
+ step_hz: step_hz
887
+ )
888
+ local_sigs = (snap[:signals] || []).select do |s|
889
+ h = s[:hz].to_i
890
+ h.between?(win_lo, win_hi) && s[:snr_db].to_f >= 8.0
891
+ end
892
+ if local_sigs.empty?
893
+ bins = snap[:spectrum] || []
894
+ narrow_lo = seed_hz - narrow_half
895
+ narrow_hi = seed_hz + narrow_half
896
+ in_win = bins.select { |b| b[:freq_hz].to_i.between?(narrow_lo, narrow_hi) }
897
+ unless in_win.empty?
898
+ best_bin = in_win.max_by { |b| b[:power_db].to_f }
899
+ nf = snap[:noise_floor_db].to_f
900
+ if best_bin[:power_db].to_f >= (nf + 8.0)
901
+ peak_hz = best_bin[:freq_hz].to_i
902
+ peak_pwr = best_bin[:power_db].to_f
903
+ peak_bw = plan_bw_hz
904
+ peak_snr = (peak_pwr - nf).round(2)
905
+ peak_nf = nf
906
+ end
907
+ end
908
+ else
909
+ near = local_sigs.select { |s| (s[:hz].to_i - seed_hz).abs <= max_delta }
910
+ pool = near.empty? ? local_sigs : near
911
+ best_snr = pool.map { |s| s[:snr_db].to_f }.max
912
+ contenders = pool.select { |s| s[:snr_db].to_f >= (best_snr - 3.0) }
913
+ best = contenders.min_by { |s| (s[:hz].to_i - seed_hz).abs }
914
+ peak_hz = best[:hz].to_i
915
+ peak_pwr = best[:power_db].to_f
916
+ peak_bw = best[:bw_hz].to_i
917
+ peak_bw = plan_bw_hz if peak_bw <= 0 || peak_bw > (plan_bw_hz * 2)
918
+ peak_snr = best[:snr_db]
919
+ peak_nf = best[:noise_floor_db]
920
+ end
921
+ # restore band-plan IF for any subsequent S-meter confirm
922
+ cmd(gqrx_sock: gqrx_sock, cmd: "M #{mode_str} #{passband_hz}", resp_ok: 'RPRT 0')
923
+ rescue StandardError => e
924
+ puts " [REFINE] local FFT failed (#{e.class}: #{e.message}) — falling back to lock-hunt"
925
+ peak_hz = nil
926
+ begin
927
+ cmd(gqrx_sock: gqrx_sock, cmd: "M #{mode_str} #{passband_hz}", resp_ok: 'RPRT 0')
928
+ rescue StandardError
929
+ nil
930
+ end
931
+ end
932
+ end
933
+
934
+ # Lock-hunt (default AND fft-fallback): probe seed_snap ± N * step_hz
935
+ # under the band-plan demod and keep the strongest above (lock - 6 dB).
936
+ # This is the accuracy fix for FM: panoramic centroids often snap one
937
+ # channel off (89.2 vs 89.1); a single S-meter probe per neighbour
938
+ # corrects it in ~150 ms vs multi-second local FFT zoom.
939
+ if peak_hz.nil? || mode == 'lock' || mode == 'hybrid'
940
+ candidates = (-hunt_steps..hunt_steps).map { |k| seed_snap + (k * step_hz) }
941
+ # Also include the raw panoramic snap if it differs from seed_snap.
942
+ candidates << ((seed_hz.to_f / step_hz).round * step_hz).to_i
943
+ candidates = candidates.uniq.select { |h| h.positive? && h.between?(win_lo - step_hz, win_hi + step_hz) }
944
+ best_h = nil
945
+ best_s = -1.0 / 0.0
946
+ scores = []
947
+ candidates.each do |h|
948
+ s = sample_smeter.call(h, n: 3, settle: 0.035)
949
+ scores << [h, s]
950
+ if s > best_s
951
+ best_s = s
952
+ best_h = h
953
+ end
954
+ end
955
+ # Accept if strong enough relative to strength_lock (S-meter domain).
956
+ # strength_lock ≈ nf+8; require at least lock-6 (≈ nf+2) so weak-but-
957
+ # real stations still lock while pure noise floor rejects.
958
+ min_accept = strength_lock - 6.0
959
+ if best_h && best_s >= min_accept
960
+ peak_hz = best_h
961
+ smeter = best_s.round(1)
962
+ peak_pwr = smeter if peak_pwr.nil?
963
+ # Optional: if two neighbours both very strong (within 3 dB) and
964
+ # both above lock, keep BOTH as separate detections later by
965
+ # returning the nearest-to-seed; dual-split happens only when
966
+ # the secondary is itself a distinct seed from panorama.
967
+ near_seed = scores.select { |_h, s| s >= (best_s - 3.0) && s >= min_accept }
968
+ .min_by { |h, _s| (h - seed_hz).abs }
969
+ if near_seed && (near_seed[0] - seed_hz).abs <= max_delta
970
+ peak_hz = near_seed[0]
971
+ smeter = near_seed[1].round(1)
972
+ end
973
+ puts " [REFINE] lock-hunt scores: #{scores.map { |h, s| "#{PWN::SDR.hz_to_s(freq: h)}=#{s.round(1)}" }.join(' ')}"
974
+ else
975
+ puts " [REFINE] lock-hunt no candidate ≥ #{min_accept.round(1)} dBFS (best=#{best_s.round(1)}) — keeping FFT estimate"
976
+ end
977
+ end
978
+
979
+ unless peak_hz
980
+ refined << det
981
+ next
982
+ end
983
+
984
+ snapped_hz = ((peak_hz.to_f / step_hz).round * step_hz).to_i
985
+ if (snapped_hz - seed_hz).abs > [max_delta, step_hz * hunt_steps].max
986
+ puts " [REFINE] snap moved Δ=#{(snapped_hz - seed_hz).abs} Hz (>#{[max_delta, step_hz * hunt_steps].max}) — keeping FFT estimate"
987
+ refined << det
988
+ next
989
+ end
990
+
991
+ # Single S-meter confirm if we only have FFT peak so far.
992
+ if smeter.nil?
993
+ begin
994
+ smeter = sample_smeter.call(snapped_hz, n: 4, settle: 0.04).round(1)
995
+ rescue StandardError
996
+ smeter = nil
997
+ end
998
+ end
999
+
1000
+ out = det.dup
1001
+ out[:raw_fft_peak_hz] = det[:raw_peak_hz] || det[:hz]
1002
+ out[:raw_fft_freq] = det[:freq]
1003
+ out[:raw_peak_hz] = peak_hz
1004
+ out[:hz] = snapped_hz
1005
+ out[:freq] = PWN::SDR.hz_to_s(freq: snapped_hz)
1006
+ out[:bw_hz] = peak_bw.positive? ? peak_bw : plan_bw_hz
1007
+ out[:noise_floor_db] = peak_nf if peak_nf
1008
+ out[:snr_db] = peak_snr if peak_snr
1009
+ out[:strength_db] = smeter || peak_pwr.to_f.round(2)
1010
+ out[:method] = mode == 'fft' ? :fast_spectrum_refined_fft_peak : :fast_spectrum_refined_lock_hunt
1011
+ out[:refined] = true
1012
+ delta_hz = (snapped_hz - seed_hz).abs
1013
+ sm_s = smeter ? "#{smeter} dBFS" : 'n/a'
1014
+ puts " [REFINE] #{PWN::SDR.hz_to_s(freq: seed_hz)} → #{out[:freq]} (Δ=#{delta_hz} Hz) strength=#{out[:strength_db]} smeter=#{sm_s} bw=#{PWN::SDR.hz_to_s(freq: out[:bw_hz])} snr=#{out[:snr_db]}"
1015
+ refined << out
1016
+ rescue StandardError => e
1017
+ puts " [REFINE] error on detection ##{idx + 1}: #{e.class}: #{e.message} — keeping FFT estimate"
1018
+ refined << det
1019
+ end
1020
+
1021
+ # De-dupe after refine — keep strongest when two seeds collapse onto one channel.
1022
+ refined.sort_by! { |d| d[:hz].to_i }
1023
+ deduped = []
1024
+ refined.each do |d|
1025
+ prev = deduped.last
1026
+ tol = fft_plan_geometry(
1027
+ plan_bw_hz: plan_bw_hz,
1028
+ step_hz: step_hz,
1029
+ measured_bw_hz: d[:bw_hz].to_i,
1030
+ res_hz: local_res
1031
+ )[:merge_tol_hz]
1032
+ # For wide plans, merge_tol of half-plan (100 kHz for FM) would
1033
+ # collapse adjacent legitimate stations. Cap at 0.6*step when step
1034
+ # is already half a channel or more.
1035
+ if plan_bw_hz >= 50_000.0
1036
+ tol = [tol, (step_hz * 0.6).to_i].min
1037
+ tol = [tol, 1].max
1038
+ end
1039
+ if prev && (d[:hz].to_i - prev[:hz].to_i).abs <= tol
1040
+ # Prefer stronger S-meter when both refined; fall back to SNR.
1041
+ prev_score = prev[:strength_db].to_f
1042
+ this_score = d[:strength_db].to_f
1043
+ if this_score == prev_score
1044
+ prev_score = prev[:snr_db].to_f
1045
+ this_score = d[:snr_db].to_f
1046
+ end
1047
+ deduped[-1] = d if this_score >= prev_score
1048
+ else
1049
+ deduped << d
1050
+ end
1051
+ end
1052
+
1053
+ puts "[REFINE] done — #{detections.length} preliminary → #{deduped.length} refined"
1054
+ deduped
1055
+ rescue StandardError => e
1056
+ raise e
1057
+ end
1058
+
552
1059
  public_class_method def self.connect(opts = {})
553
1060
  target = opts[:target] ||= '127.0.0.1'
554
1061
  port = opts[:port] ||= 7356
@@ -565,7 +1072,9 @@ module PWN
565
1072
  # demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
566
1073
  # bandwidth: 'optional - Bandwidth (defaults to "200.000")',
567
1074
  # squelch: 'optional - Squelch level to set (Defaults to current value)',
568
- # decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
1075
+ # decoder: 'optional - Decoder key (e.g., :gsm / :rds) to start live decoding (starts recording if provided)',
1076
+ # interactive: 'optional - Boolean; when false AND decoder responds to .sample, call sample (non-interactive Hash) instead of decode (TTY). Defaults to true.',
1077
+ # settle_secs: 'optional - Seconds for decoder.sample (e.g. RDS; default 8)',
569
1078
  # udp_ip: 'optional - UDP IP address for decoder module (defaults to 127.0.0.1)',
570
1079
  # udp_port: 'optional - UDP port for decoder module (defaults to 7355)',
571
1080
  # suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
@@ -596,6 +1105,10 @@ module PWN
596
1105
  bandwidth = opts[:bandwidth] ||= '200.000'
597
1106
  squelch = opts[:squelch]
598
1107
  decoder = opts[:decoder]
1108
+ # interactive: false → prefer decoder.sample (structured Hash) over
1109
+ # decoder.decode (TTY spinner). Used by agents / Extrospection / cron.
1110
+ interactive = opts.key?(:interactive) ? !opts[:interactive].nil? && opts[:interactive] != false : true
1111
+ settle_secs = opts[:settle_secs]
599
1112
  udp_ip = opts[:udp_ip]
600
1113
  udp_port = opts[:udp_port]
601
1114
  suppress_details = opts[:suppress_details] || false
@@ -681,11 +1194,22 @@ module PWN
681
1194
  decoder_module = PWN::SDR::Decoder.resolve(decoder: decoder)
682
1195
 
683
1196
  # Initialize and start decoder (uniform .decode(freq_obj:) API).
1197
+ # When interactive:false and the module exposes .sample (e.g.
1198
+ # Decoder::RDS), return a structured Hash instead of the TTY loop.
684
1199
  freq_obj[:gqrx_sock] = gqrx_sock
685
1200
  freq_obj[:udp_ip] = udp_ip
686
1201
  freq_obj[:udp_port] = udp_port
687
1202
  freq_obj[:decoder_module] = decoder_module
688
- decoder_module.decode(freq_obj: freq_obj)
1203
+ if !interactive && decoder_module.respond_to?(:sample)
1204
+ sample_opts = {
1205
+ freq_obj: freq_obj,
1206
+ gqrx_sock: gqrx_sock
1207
+ }
1208
+ sample_opts[:settle_secs] = settle_secs unless settle_secs.nil?
1209
+ freq_obj[:sample] = decoder_module.sample(sample_opts)
1210
+ else
1211
+ decoder_module.decode(freq_obj: freq_obj)
1212
+ end
689
1213
  end
690
1214
  end
691
1215
 
@@ -732,8 +1256,16 @@ module PWN
732
1256
 
733
1257
  step_hz = 10**(precision - 1)
734
1258
 
735
- strength_lock = opts[:strength_lock] ||= -70.0
736
- squelch = opts[:squelch] ||= (strength_lock - 3.0)
1259
+ # Honour explicit CLI/API -S / -Q. Default strength_lock = -70.0 is only
1260
+ # a starting guess and is re-calibrated against the live S-meter noise
1261
+ # floor below unless the user provided -S. Stored squelch is always a
1262
+ # few dB under strength_lock for later decoder re-tunes; during the
1263
+ # candidate / edge walk itself we open GQRX SQL fully so I/Q + S-meter
1264
+ # are never muted.
1265
+ user_strength_lock = !opts[:strength_lock].nil?
1266
+ user_squelch = !opts[:squelch].nil?
1267
+ strength_lock = (opts[:strength_lock] || -70.0).to_f
1268
+ squelch = (opts[:squelch] || (strength_lock - 3.0)).to_f
737
1269
  raise 'ERROR: squelch must always be less than strength_lock.' if squelch >= strength_lock
738
1270
 
739
1271
  decoder = opts[:decoder]
@@ -789,10 +1321,34 @@ module PWN
789
1321
  precision: precision,
790
1322
  step_hz_direction: step_hz_direction
791
1323
  )
792
- if squelch < noise_floor
793
- squelch = noise_floor.round + 7
794
- strength_lock = squelch + 3.0
795
- puts "Adjusted strength_lock to #{strength_lock} dBFS and squelch to #{squelch} dBFS based on measured noise floor. This ensures proper signal detection..."
1324
+ # Auto-calibrate strength_lock from live S-meter noise floor unless
1325
+ # the user passed an explicit -S. The historical rule
1326
+ # squelch = nf.round + 7; strength_lock = squelch + 3
1327
+ # RAISED the gate into the signal cloud on dense bands (fm_radio
1328
+ # stations sit only ~10–20 dB above the quiet floor) which made
1329
+ # edge_detection refuse to drop and paint multi-MHz occupied-BW
1330
+ # blobs. Mirror #fast_scan_range: lock = nf + 8, squelch = lock - 3.
1331
+ if user_strength_lock
1332
+ if !user_squelch && squelch < noise_floor
1333
+ # Explicit -S but no -Q: keep lock, just park squelch under the
1334
+ # quieter of (lock-3, nf+1) so decoder SQL is sane later.
1335
+ squelch = [strength_lock - 3.0, noise_floor.to_f + 1.0].min.round(1)
1336
+ puts "[SCAN] derived squelch=#{squelch} dBFS under strength_lock=#{strength_lock} (nf≈#{noise_floor})"
1337
+ end
1338
+ else
1339
+ # nf + 12 dB: high enough that quiet interstitial spectrum
1340
+ # (side-lobes, distant carriers) stays under the gate, low
1341
+ # enough that real FM main lobes (~15–30 dB above nf) still
1342
+ # trip. The historical nf.round+7 formula sat *inside* the
1343
+ # carrier cloud and refused to drop on edges.
1344
+ auto_lock = (noise_floor.to_f + 12.0).round(1)
1345
+ puts "[SCAN] auto strength_lock: live S-meter nf≈#{noise_floor} dBFS → lock=#{auto_lock} dBFS (was #{strength_lock})"
1346
+ strength_lock = auto_lock
1347
+ squelch = (strength_lock - 3.0).round(1) unless user_squelch
1348
+ end
1349
+ if squelch >= strength_lock
1350
+ squelch = (strength_lock - 3.0).round(1)
1351
+ puts "[SCAN] clamped squelch to #{squelch} dBFS (< strength_lock #{strength_lock})"
796
1352
  end
797
1353
 
798
1354
  # Begin scanning range
@@ -804,24 +1360,28 @@ module PWN
804
1360
  puts "SESSION PARAMS >> Continuously Loop through Scan Range(s): #{keep_looping}"
805
1361
  puts "\nIf scans are slow and/or you're experiencing false positives/negatives,"
806
1362
  puts 'consider adjusting the following:'
807
- puts "1. The SDR's sample rate in GQRX"
808
- puts "\s\s- Click on `Configure I/O devices`."
809
- puts "\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000)."
1363
+ puts "1. The SDR's sample rate in GQRX (device Input rate — NOT remote-control)"
1364
+ puts "\s\s- Prefer: PWN::SDR::GQRX.apply_band_plan_input_rate(band_plan: :fm_radio, restart: true)"
1365
+ puts "\s\s- Or: click Configure I/O devices and set Input rate to the band-plan input_rate."
1366
+ puts "\s\s- Lower Input rate can improve regular-scan responsiveness (e.g. Pluto/HackRF ~ 1e6 for FM)."
810
1367
  puts '2. Adjust the :strength_lock parameter.'
811
1368
  puts '3. Adjust the :precision parameter.'
812
- puts '4. Disable AI introspection in PWN::Env'
1369
+ puts '4. Disable AI module_reflection in PWN::Env'
813
1370
  puts 'Happy scanning!'
814
1371
  puts '-' * 86
815
1372
  # print 'Pressing ENTER to begin scan...'
816
1373
  # gets
817
1374
  puts "\n\n\n"
818
1375
 
819
- # Set squelch once for each range
1376
+ # Floor GQRX SQL during the candidate / edge / peak walk so the
1377
+ # S-meter is never muted. The calibrated :squelch is stored on
1378
+ # each detection for later decoder re-tunes (analyze_scan).
820
1379
  change_squelch_resp = cmd(
821
1380
  gqrx_sock: gqrx_sock,
822
- cmd: "L SQL #{squelch}",
1381
+ cmd: 'L SQL -150.0',
823
1382
  resp_ok: 'RPRT 0'
824
1383
  )
1384
+ puts "[SCAN] GQRX SQL floored to -150.0 for S-meter walks (stored squelch=#{squelch} dBFS, strength_lock=#{strength_lock} dBFS)"
825
1385
 
826
1386
  # We always disable RDS decoding during the scan
827
1387
  # to prevent unnecessary processing overhead.
@@ -910,12 +1470,29 @@ module PWN
910
1470
  if strength_db >= strength_lock
911
1471
  puts '-' * 86
912
1472
  # Find left and right edges of the signal
1473
+ # Bound the edge walk to ~± plan_bw (or a few raster steps) so
1474
+ # adjacent co-channel carriers (fm_radio 200 kHz spacing) cannot
1475
+ # merge into a multi-MHz "occupied" lobe when strength_lock is
1476
+ # a hair too low. Hard bounds reuse the optional min_hz/max_hz
1477
+ # already supported by #edge_detection for #refine_detections.
1478
+ plan_bw_for_edge = PWN::SDR.hz_to_i(freq: bandwidth)
1479
+ plan_bw_for_edge = step_hz if plan_bw_for_edge.zero?
1480
+ # Keep the next co-channel neighbour outside the window:
1481
+ # half ≈ 0.6·plan (capped at plan itself), floored by the raster.
1482
+ half_edge = [
1483
+ (plan_bw_for_edge * 0.6).to_i,
1484
+ step_hz
1485
+ ].max
1486
+ half_edge = [half_edge, plan_bw_for_edge].min
1487
+ half_edge = step_hz if half_edge < 1
913
1488
  candidate_signals = edge_detection(
914
1489
  gqrx_sock: gqrx_sock,
915
1490
  hz: hz,
916
1491
  step_hz: step_hz,
917
1492
  precision: precision,
918
- strength_lock: strength_lock
1493
+ strength_lock: strength_lock,
1494
+ min_hz: hz - half_edge,
1495
+ max_hz: hz + half_edge
919
1496
  )
920
1497
  elsif candidate_signals.length.positive?
921
1498
  best_peak = find_best_peak(
@@ -926,7 +1503,11 @@ module PWN
926
1503
  strength_lock: strength_lock
927
1504
  )
928
1505
 
929
- if best_peak[:hz] && best_peak[:strength_db] > strength_lock
1506
+ # Accept peaks a few dB under strength_lock: the candidate
1507
+ # gate already verified something in this window was hot,
1508
+ # and find_best_peak averages multi-pass samples so a peaky
1509
+ # FM main lobe can report slightly under the trip level.
1510
+ if best_peak[:hz] && best_peak[:strength_db] > (strength_lock - 3.0)
930
1511
  puts "\n**** Detected Signal ****"
931
1512
  best_freq = PWN::SDR.hz_to_s(freq: best_peak[:hz])
932
1513
  best_strength_db = best_peak[:strength_db]
@@ -942,15 +1523,55 @@ module PWN
942
1523
  keep_alive: true
943
1524
  )
944
1525
  prev_freq_obj[:strength_lock] = strength_lock
945
- prev_freq_obj[:strength_db] = best_strength_db.round(1)
1526
+ prev_freq_obj[:strength_db] = best_strength_db.round(2)
946
1527
  prev_freq_obj[:iteration] = iteration_total
947
1528
 
948
- ai_analysis = PWN::AI::Agent::GQRX.analyze(
949
- request: prev_freq_obj.to_json,
950
- location: location
951
- )
952
-
953
- prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
1529
+ # Schema parity with #fast_scan_range signals so both
1530
+ # modes emit the same key set (see log_signals / example
1531
+ # scan JSON). Values are derived from edge detection +
1532
+ # the measured noise floor rather than an FFT bin map.
1533
+ best_hz = PWN::SDR.hz_to_i(freq: best_peak[:hz])
1534
+ edge_hzs = candidate_signals.map { |s| PWN::SDR.hz_to_i(freq: s[:hz]) }
1535
+ edge_lo = edge_hzs.min
1536
+ edge_hi = edge_hzs.max
1537
+ occupied_bw_hz = ((edge_hi - edge_lo).abs + step_hz).to_i
1538
+ plan_bw_for_sig = PWN::SDR.hz_to_i(freq: bandwidth)
1539
+ plan_bw_for_sig = step_hz if plan_bw_for_sig.zero?
1540
+ # Prefer measured occupied BW when the edge walk resolved a
1541
+ # plausible span (≤ 2× plan BW). Anything wider is almost
1542
+ # always a multi-station merge from an under-shot lock —
1543
+ # fall back to the band-plan channel width instead of
1544
+ # advertising multi-MHz "signals".
1545
+ max_plausible_bw = [plan_bw_for_sig * 2, step_hz * 4, plan_bw_for_sig].max
1546
+ sig_bw_hz = if occupied_bw_hz.positive? && occupied_bw_hz <= max_plausible_bw
1547
+ occupied_bw_hz
1548
+ else
1549
+ plan_bw_for_sig
1550
+ end
1551
+ nf_db = noise_floor.to_f
1552
+ snr = (best_strength_db.to_f - nf_db).round(2)
1553
+ prev_freq_obj[:hz] = best_hz
1554
+ prev_freq_obj[:raw_peak_hz] = best_hz
1555
+ prev_freq_obj[:bw_hz] = sig_bw_hz
1556
+ prev_freq_obj[:snr_db] = snr
1557
+ prev_freq_obj[:prominence_db] = snr
1558
+ prev_freq_obj[:noise_floor_db] = nf_db.round(2)
1559
+ prev_freq_obj[:chunk_center] = PWN::SDR.hz_to_s(freq: ((edge_lo + edge_hi) / 2.0).to_i)
1560
+ prev_freq_obj[:method] = :iterative_edge_peak
1561
+
1562
+ # Soft-fail AI analysis so a missing PWN::Env[:ai] (or
1563
+ # engine outage) never aborts an otherwise successful
1564
+ # detection. module_reflection=false or bare `require
1565
+ # 'pwn'` without Driver::Parser both leave Env[:ai] nil.
1566
+ begin
1567
+ ai_analysis = PWN::AI::Agent::GQRX.analyze(
1568
+ request: prev_freq_obj.to_json,
1569
+ location: location
1570
+ )
1571
+ prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
1572
+ rescue StandardError => e
1573
+ puts "[SCAN] AI analysis skipped: #{e.class}: #{e.message}"
1574
+ end
954
1575
  puts JSON.pretty_generate(prev_freq_obj)
955
1576
  puts '-' * 86
956
1577
  puts "\n\n\n"
@@ -958,7 +1579,12 @@ module PWN
958
1579
  log_signals(
959
1580
  signals_detected: signals_detected,
960
1581
  timestamp_start: timestamp_start,
961
- scan_log: scan_log
1582
+ scan_log: scan_log,
1583
+ meta: {
1584
+ precision: precision,
1585
+ plan_bw_hz: plan_bw_for_sig,
1586
+ method: :scan_range
1587
+ }
962
1588
  )
963
1589
  hz = candidate_signals.last[:hz]
964
1590
  # gets
@@ -971,7 +1597,12 @@ module PWN
971
1597
  log_signals(
972
1598
  signals_detected: signals_detected,
973
1599
  timestamp_start: timestamp_start,
974
- scan_log: scan_log
1600
+ scan_log: scan_log,
1601
+ meta: {
1602
+ precision: precision,
1603
+ plan_bw_hz: PWN::SDR.hz_to_i(freq: bandwidth),
1604
+ method: :scan_range
1605
+ }
975
1606
  )
976
1607
  end
977
1608
  break unless keep_looping
@@ -1014,7 +1645,12 @@ module PWN
1014
1645
  signals_detected: signals_detected,
1015
1646
  timestamp_start: timestamp_start,
1016
1647
  scan_log: scan_log,
1017
- iteration_metrics: iteration_metrics
1648
+ iteration_metrics: iteration_metrics,
1649
+ meta: {
1650
+ precision: precision,
1651
+ plan_bw_hz: PWN::SDR.hz_to_i(freq: bandwidth),
1652
+ method: :scan_range
1653
+ }
1018
1654
  )
1019
1655
  end
1020
1656
  rescue Interrupt
@@ -1223,6 +1859,358 @@ module PWN
1223
1859
  raise e
1224
1860
  end
1225
1861
 
1862
+ # ------------------------------------------------------------------
1863
+ # GQRX *device* sample-rate configuration
1864
+ #
1865
+ # The remote-control protocol (see #cmd header / remote-control.txt)
1866
+ # exposes F/M/L/U/… but NOT input sample rate. GQRX's Input rate is a
1867
+ # hardware/device-dialog setting persisted under:
1868
+ #
1869
+ # ~/.config/gqrx/<session>.conf → [input] sample_rate=<int>
1870
+ #
1871
+ # and only applied when GQRX (re)opens the SDR. Therefore the only
1872
+ # reliable way to configure sample rate for the *currently connected*
1873
+ # SDR is:
1874
+ # 1. resolve the active conf (+ optional SoapySDR probe for legal rates)
1875
+ # 2. rewrite [input] sample_rate=
1876
+ # 3. bounce GQRX (or prompt the operator to reopen I/O devices)
1877
+ #
1878
+ # Band-plan defaults live on
1879
+ # PWN::SDR::FrequencyAllocation.band_plans[<plan>][:input_rate]
1880
+ # (e.g. fm_radio → 1_000_000, ads_b* → 2_000_000). Prefer
1881
+ # #set_input_rate / #apply_band_plan_input_rate over hand edits.
1882
+ # ------------------------------------------------------------------
1883
+
1884
+ # Supported Method Parameters::
1885
+ # path = PWN::SDR::GQRX.config_path(
1886
+ # path: 'optional - absolute path to a GQRX .conf (defaults to ~/.config/gqrx/default.conf or recentconfig)'
1887
+ # )
1888
+ public_class_method def self.config_path(opts = {})
1889
+ return opts[:path].to_s if opts[:path] && !opts[:path].to_s.empty?
1890
+
1891
+ conf_dir = File.join(Dir.home, '.config', 'gqrx')
1892
+ recent = File.join(conf_dir, 'recentconfig.cfg')
1893
+ if File.file?(recent)
1894
+ # recentconfig.cfg is usually a single path line
1895
+ candidate = File.read(recent).to_s.lines.map(&:strip).find { |l| !l.empty? && !l.start_with?('#') }
1896
+ return candidate if candidate && File.file?(candidate)
1897
+ end
1898
+
1899
+ default = File.join(conf_dir, 'default.conf')
1900
+ return default if File.file?(default)
1901
+
1902
+ # last resort: newest *.conf
1903
+ newest = Dir.glob(File.join(conf_dir, '*.conf')).max_by { |f| File.mtime(f) }
1904
+ return newest if newest
1905
+
1906
+ default
1907
+ rescue StandardError => e
1908
+ raise e
1909
+ end
1910
+
1911
+ # Supported Method Parameters::
1912
+ # info = PWN::SDR::GQRX.read_input_config(
1913
+ # path: 'optional - GQRX conf path (defaults to #config_path)'
1914
+ # )
1915
+ # Returns Hash with :path, :device, :frequency, :input_rate, :raw
1916
+ public_class_method def self.read_input_config(opts = {})
1917
+ path = config_path(path: opts[:path])
1918
+ raise "ERROR: GQRX conf not found: #{path}" unless File.file?(path)
1919
+
1920
+ text = File.read(path)
1921
+ # Restrict to the [input] section (until next [section] or EOF).
1922
+ input = text[/^\[input\][^\[]*/m].to_s
1923
+ raise "ERROR: no [input] section in #{path}" if input.empty?
1924
+
1925
+ device = input[/^device=(.*)$/, 1].to_s.strip
1926
+ # strip surrounding quotes GQRX sometimes writes
1927
+ device = device.sub(/\A"(.*)"\z/, '\1')
1928
+ freq_s = input[/^frequency=(.*)$/, 1].to_s.strip
1929
+ rate_s = input[/^sample_rate=(.*)$/, 1].to_s.strip
1930
+
1931
+ {
1932
+ path: path,
1933
+ device: device,
1934
+ frequency: (freq_s.empty? ? nil : freq_s.to_i),
1935
+ input_rate: (rate_s.empty? ? nil : rate_s.to_i),
1936
+ raw: input
1937
+ }
1938
+ rescue StandardError => e
1939
+ raise e
1940
+ end
1941
+
1942
+ # Supported Method Parameters::
1943
+ # rates = PWN::SDR::GQRX.device_input_rates(
1944
+ # device: 'optional - SoapySDR args string (defaults to [input] device= from conf)',
1945
+ # path: 'optional - GQRX conf path used when :device omitted'
1946
+ # )
1947
+ # Returns Array<Integer> of legal sample rates (Hz). Empty when unprobeable.
1948
+ public_class_method def self.device_input_rates(opts = {})
1949
+ device = opts[:device].to_s
1950
+ if device.empty?
1951
+ conf = read_input_config(path: opts[:path])
1952
+ device = conf[:device].to_s
1953
+ end
1954
+
1955
+ # Prefer SoapySDRUtil --probe (works for every Soapy driver GQRX uses).
1956
+ # device strings look like:
1957
+ # "device=HackRF Pro,driver=hackrf,..." or "driver=plutosdr,..."
1958
+ # Conf may be stale (e.g. still names Pluto while a HackRF is what is
1959
+ # actually attached) — fall through a probe chain until rates resolve.
1960
+ candidates = []
1961
+ candidates << device unless device.empty?
1962
+ if (driver = device[/driver=([^,]+)/, 1])
1963
+ candidates << "driver=#{driver}"
1964
+ end
1965
+ begin
1966
+ find_out = `SoapySDRUtil --find 2>/dev/null`.to_s
1967
+ find_out.scan(/driver\s*=\s*(\S+)/i).flatten.uniq.each do |d|
1968
+ candidates << "driver=#{d}"
1969
+ end
1970
+ rescue StandardError
1971
+ nil
1972
+ end
1973
+ candidates.uniq!
1974
+
1975
+ out = ''
1976
+ candidates.each do |probe_arg|
1977
+ next if probe_arg.to_s.empty?
1978
+
1979
+ candidate_out = `SoapySDRUtil --probe="#{probe_arg.to_s.gsub('"', '\\"')}" 2>/dev/null`
1980
+ if candidate_out.to_s =~ /Sample rates:/i
1981
+ out = candidate_out
1982
+ break
1983
+ end
1984
+ end
1985
+ return [] if out.to_s.strip.empty?
1986
+
1987
+ rates = []
1988
+ out.each_line do |line|
1989
+ next unless line =~ /Sample rates:\s*(.+)/i
1990
+
1991
+ spec = Regexp.last_match(1).strip
1992
+ # Formats observed:
1993
+ # "1, 2, 3, 4, 5, ..., 16, 17, 18, 19, 20 MSps"
1994
+ # "0.25, 0.5, ..., 3.2 MSps"
1995
+ # "250000, 1024000, 1800000, 2400000, 3200000 Hz"
1996
+ unit = :hz
1997
+ unit = :msps if spec =~ %r{MSps|MS/s|MHz}i
1998
+ unit = :ksps if spec =~ %r{kSps|kS/s|kHz}i
1999
+
2000
+ nums = spec.scan(/(\d+(?:\.\d+)?)/).flatten.map(&:to_f)
2001
+ # When "...," range ellipsis is present with endpoints, expand integers.
2002
+ if spec.include?('...') && nums.length >= 2 && unit == :msps
2003
+ # HackRF style: 1,2,3,...,20 → integer Msps
2004
+ lo = nums.first
2005
+ hi = nums.last
2006
+ step = 1.0
2007
+ step = nums[1] - nums[0] if nums.length >= 3 && (nums[1] - nums[0]).positive?
2008
+ n = lo
2009
+ while n <= hi + 1e-9
2010
+ rates << (n * 1_000_000).round
2011
+ n += step
2012
+ end
2013
+ else
2014
+ nums.each do |n|
2015
+ hz = case unit
2016
+ when :msps then (n * 1_000_000).round
2017
+ when :ksps then (n * 1_000).round
2018
+ else n.round
2019
+ end
2020
+ rates << hz
2021
+ end
2022
+ end
2023
+ end
2024
+ rates.uniq.sort
2025
+ rescue StandardError
2026
+ []
2027
+ end
2028
+
2029
+ # Supported Method Parameters::
2030
+ # hz = PWN::SDR::GQRX.nearest_input_rate(
2031
+ # input_rate: 'required - desired input rate Hz',
2032
+ # rates: 'optional - Array of legal rates (defaults to #device_input_rates)'
2033
+ # )
2034
+ # Snaps desired rate onto the closest legal device rate (>= preferred when
2035
+ # possible, else nearest). Returns desired unchanged when rates empty.
2036
+ public_class_method def self.nearest_input_rate(opts = {})
2037
+ desired = (opts[:input_rate] || opts[:sample_rate]).to_i
2038
+ raise 'ERROR: :input_rate required' unless desired.positive?
2039
+
2040
+ rates = opts[:rates]
2041
+ rates = device_input_rates(device: opts[:device], path: opts[:path]) if rates.nil?
2042
+ return desired if rates.nil? || rates.empty?
2043
+
2044
+ # Prefer the smallest legal rate that still covers the ask (Nyquist /
2045
+ # panoramic span), else the max the hardware can do.
2046
+ ge = rates.select { |r| r >= desired }
2047
+ return ge.min if ge.any?
2048
+
2049
+ rates.max
2050
+ rescue StandardError => e
2051
+ raise e
2052
+ end
2053
+
2054
+ # Supported Method Parameters::
2055
+ # result = PWN::SDR::GQRX.set_input_rate(
2056
+ # input_rate: 'required - desired GQRX Input rate in Hz (Integer)',
2057
+ # path: 'optional - GQRX conf path (defaults to #config_path)',
2058
+ # clamp: 'optional - snap to nearest legal device rate (default true)',
2059
+ # restart: 'optional - kill+respawn gqrx so the new rate is applied (default false)',
2060
+ # gqrx_bin: 'optional - gqrx binary path when restart:true (default `which gqrx`)'
2061
+ # )
2062
+ # Rewrites [input] sample_rate= in the active GQRX conf. Does NOT use the
2063
+ # remote-control socket — input rate is not part of that protocol.
2064
+ # Returns { path:, previous:, input_rate:, clamped_from:, restarted:, device: }.
2065
+ public_class_method def self.set_input_rate(opts = {})
2066
+ desired = (opts[:input_rate] || opts[:sample_rate]).to_i
2067
+ raise 'ERROR: :input_rate required (positive Integer Hz)' unless desired.positive?
2068
+
2069
+ path = config_path(path: opts[:path])
2070
+ conf = read_input_config(path: path)
2071
+ previous = conf[:input_rate]
2072
+ device = conf[:device]
2073
+
2074
+ applied = desired
2075
+ clamped_from = nil
2076
+ clamp = opts.key?(:clamp) ? !opts[:clamp].nil? && opts[:clamp] != false : true
2077
+ if clamp
2078
+ snapped = nearest_input_rate(
2079
+ input_rate: desired,
2080
+ device: device,
2081
+ path: path
2082
+ )
2083
+ if snapped != desired
2084
+ clamped_from = desired
2085
+ applied = snapped
2086
+ end
2087
+ end
2088
+
2089
+ text = File.read(path)
2090
+ if text.match?(/^\[input\][^\[]*sample_rate=/m)
2091
+ text = text.sub(/^(sample_rate=).*$/, "\\1#{applied}")
2092
+ else
2093
+ # Insert sample_rate under [input] if the key is somehow missing.
2094
+ text = text.sub(/^\[input\]\s*$/, "[input]\nsample_rate=#{applied}")
2095
+ end
2096
+ File.write(path, text)
2097
+
2098
+ restarted = false
2099
+ if opts[:restart]
2100
+ restarted = restart_gqrx(
2101
+ gqrx_bin: opts[:gqrx_bin],
2102
+ conf_path: path
2103
+ )
2104
+ end
2105
+
2106
+ {
2107
+ path: path,
2108
+ previous: previous,
2109
+ input_rate: applied,
2110
+ clamped_from: clamped_from,
2111
+ restarted: restarted,
2112
+ device: device,
2113
+ note: restarted ? 'GQRX restarted to apply input_rate' : 'Restart GQRX (or reopen I/O devices) to apply input_rate'
2114
+ }
2115
+ rescue StandardError => e
2116
+ raise e
2117
+ end
2118
+
2119
+ # Supported Method Parameters::
2120
+ # result = PWN::SDR::GQRX.apply_band_plan_input_rate(
2121
+ # band_plan: 'required - key from PWN::SDR::FrequencyAllocation.band_plans (e.g. :fm_radio, :ads_b1090)',
2122
+ # path: 'optional - GQRX conf path',
2123
+ # clamp: 'optional - snap to device-legal rate (default true)',
2124
+ # restart: 'optional - bounce GQRX after write (default false)',
2125
+ # input_rate: 'optional - override the band-plan recommended rate'
2126
+ # )
2127
+ # Looks up FrequencyAllocation[:input_rate], clamps to the currently
2128
+ # configured SDR's legal rates, and writes it into GQRX's conf.
2129
+ public_class_method def self.apply_band_plan_input_rate(opts = {})
2130
+ key = opts[:band_plan] || opts[:profile] || opts[:assume_band_plan]
2131
+ raise 'ERROR: :band_plan required' if key.nil?
2132
+
2133
+ plans = PWN::SDR::FrequencyAllocation.band_plans
2134
+ plan_key = key.to_s.strip.downcase.tr('-', '_').to_sym
2135
+ plan = plans[plan_key]
2136
+ raise "ERROR: unknown band plan #{key.inspect}. Known: #{plans.keys.sort.join(', ')}" if plan.nil?
2137
+
2138
+ desired = (opts[:input_rate] || opts[:sample_rate] || plan[:input_rate] || 1_000_000).to_i
2139
+ result = set_input_rate(
2140
+ input_rate: desired,
2141
+ path: opts[:path],
2142
+ clamp: opts.fetch(:clamp, true),
2143
+ restart: opts[:restart],
2144
+ gqrx_bin: opts[:gqrx_bin]
2145
+ )
2146
+ result.merge(
2147
+ band_plan: plan_key,
2148
+ band_plan_input_rate: plan[:input_rate],
2149
+ bandwidth: plan[:bandwidth],
2150
+ demodulator_mode: plan[:demodulator_mode]
2151
+ )
2152
+ rescue StandardError => e
2153
+ raise e
2154
+ end
2155
+
2156
+ # Supported Method Parameters::
2157
+ # ok = PWN::SDR::GQRX.restart_gqrx(
2158
+ # gqrx_bin: 'optional - path to gqrx binary',
2159
+ # conf_path: 'optional - pass -c <conf> on relaunch'
2160
+ # )
2161
+ # Best-effort: SIGTERM any running gqrx, then relaunch detached.
2162
+ # Returns true when a new process was spawned.
2163
+ public_class_method def self.restart_gqrx(opts = {})
2164
+ bin = opts[:gqrx_bin].to_s
2165
+ bin = `which gqrx 2>/dev/null`.to_s.strip if bin.empty?
2166
+ raise 'ERROR: gqrx binary not found' if bin.empty? || !File.executable?(bin)
2167
+
2168
+ # Graceful stop so conf is flushed.
2169
+ pids = `pgrep -x gqrx 2>/dev/null`.to_s.split.map(&:to_i)
2170
+ pids.each do |pid|
2171
+ Process.kill('TERM', pid)
2172
+ rescue Errno::ESRCH
2173
+ nil
2174
+ end
2175
+ # Wait up to ~5s for exit
2176
+ 50.times do
2177
+ break if `pgrep -x gqrx 2>/dev/null`.to_s.strip.empty?
2178
+
2179
+ sleep 0.1
2180
+ end
2181
+ # Force if still up
2182
+ `pgrep -x gqrx 2>/dev/null`.to_s.split.map(&:to_i).each do |pid|
2183
+ Process.kill('KILL', pid)
2184
+ rescue Errno::ESRCH
2185
+ nil
2186
+ end
2187
+
2188
+ conf = opts[:conf_path].to_s
2189
+ conf = config_path if conf.empty?
2190
+ cmd = if conf && File.file?(conf)
2191
+ [bin, '-c', conf]
2192
+ else
2193
+ [bin]
2194
+ end
2195
+
2196
+ pid = spawn(
2197
+ *cmd,
2198
+ out: '/dev/null',
2199
+ err: '/dev/null',
2200
+ pgroup: true
2201
+ )
2202
+ Process.detach(pid)
2203
+ # Brief wait for remote control to come up (best effort).
2204
+ 30.times do
2205
+ break if system('bash', '-c', 'exec 3<>/dev/tcp/127.0.0.1/7356', out: File::NULL, err: File::NULL)
2206
+
2207
+ sleep 0.2
2208
+ end
2209
+ true
2210
+ rescue StandardError => e
2211
+ raise e
2212
+ end
2213
+
1226
2214
  # Author(s):: 0day Inc. <support@0dayinc.com>
1227
2215
 
1228
2216
  public_class_method def self.get_spectrum_snapshot(opts = {})
@@ -1253,7 +2241,7 @@ module PWN
1253
2241
  end
1254
2242
  sleep 0.02
1255
2243
  cmd(gqrx_sock: gqrx_sock, cmd: 'U IQRECORD 1', resp_ok: 'RPRT 0')
1256
- sleep(capture_secs + 0.12)
2244
+ sleep(capture_secs + 0.06)
1257
2245
  cmd(gqrx_sock: gqrx_sock, cmd: 'U IQRECORD 0', resp_ok: 'RPRT 0')
1258
2246
 
1259
2247
  # Newest raw I/Q produced by GQRX (usually ~/gqrx_*.raw , float32 I/Q interleaved @ sample_rate)
@@ -1267,74 +2255,112 @@ module PWN
1267
2255
  start_pos = [0, fsize - total_bytes].max
1268
2256
  raw_bytes = File.binread(iq_raw_file, total_bytes, start_pos)
1269
2257
 
1270
- # ---- Pure-Ruby FFT + SDRangel-style peak detection (no external interpreters) ----
2258
+ # ---- FFT (FFTW3 when available, pure-Ruby Cooley-Tukey fallback) ----
1271
2259
  raise 'ERROR: I/Q read empty or short' if raw_bytes.nil? || raw_bytes.bytesize < 8
1272
2260
 
1273
2261
  # GQRX raw I/Q: little-endian float32 interleaved I,Q,I,Q,...
1274
2262
  floats = raw_bytes.unpack('e*')
1275
2263
  n_iq = [floats.length / 2, num_samples].min
1276
- iq = Array.new(n_iq) { |i| Complex(floats[2 * i], floats[(2 * i) + 1]) }
2264
+ # Keep flat interleaved I/Q for FFTW path; Complex array for Ruby path.
2265
+ raise "ERROR: nfft (#{nfft}) must be a power of two" unless nfft.nobits?(nfft - 1)
1277
2266
 
1278
- # Hann window
1279
2267
  two_pi = 2.0 * Math::PI
1280
2268
  hann = Array.new(nfft) { |k| 0.5 * (1.0 - Math.cos(two_pi * k / (nfft - 1))) }
2269
+ hop = [nfft / 2, 1].max
2270
+ use_fftw = begin
2271
+ defined?(PWN::FFI::FFTW) && PWN::FFI::FFTW.available?
2272
+ rescue StandardError
2273
+ false
2274
+ end
1281
2275
 
1282
- # Iterative in-place radix-2 Cooley-Tukey FFT (nfft must be a power of two)
1283
- raise "ERROR: nfft (#{nfft}) must be a power of two" unless nfft.nobits?(nfft - 1)
1284
-
1285
- log2n = Math.log2(nfft).to_i
1286
- fft_proc = lambda do |x|
1287
- n = x.length
1288
- # bit-reversal permutation
1289
- j = 0
1290
- (0...(n - 1)).each do |i|
1291
- x[i], x[j] = x[j], x[i] if i < j
1292
- k = n >> 1
1293
- while k <= j
1294
- j -= k
1295
- k >>= 1
2276
+ specs = []
2277
+ if use_fftw
2278
+ # Native complex DFT via libfftw3f — orders of magnitude faster than
2279
+ # pure-Ruby butterflies at nfft>=2048 / avg>=4. Flat interleaved
2280
+ # windowed block is handed to cfft; we fftshift |X|^2 ourselves.
2281
+ pos = 0
2282
+ while (pos + nfft) <= n_iq && specs.length < avg
2283
+ blk = Array.new(2 * nfft)
2284
+ nfft.times do |k|
2285
+ base = 2 * (pos + k)
2286
+ w = hann[k]
2287
+ blk[2 * k] = floats[base].to_f * w
2288
+ blk[(2 * k) + 1] = floats[base + 1].to_f * w
1296
2289
  end
1297
- j += k
1298
- end
1299
- # butterflies
1300
- (1..log2n).each do |stage|
1301
- m = 1 << stage
1302
- half = m >> 1
1303
- wm = Complex.polar(1.0, -two_pi / m)
1304
- (0...n).step(m) do |kk|
1305
- w = Complex(1.0, 0.0)
1306
- (0...half).each do |jj|
1307
- t = w * x[kk + jj + half]
1308
- u = x[kk + jj]
1309
- x[kk + jj] = u + t
1310
- x[kk + jj + half] = u - t
1311
- w *= wm
2290
+ begin
2291
+ pairs = PWN::FFI::FFTW.cfft(iq: blk, n: nfft)
2292
+ half = nfft / 2
2293
+ # fftshift: out[0:half]=in[half:n], out[half:n]=in[0:half]
2294
+ # bins aligned -sr/2 .. +sr/2 matching pure-Ruby path.
2295
+ shifted_pwr = Array.new(nfft)
2296
+ (0...half).each do |i|
2297
+ re, im = pairs[i + half]
2298
+ shifted_pwr[i] = (re * re) + (im * im)
2299
+ re, im = pairs[i]
2300
+ shifted_pwr[i + half] = (re * re) + (im * im)
1312
2301
  end
2302
+ specs << shifted_pwr
2303
+ rescue StandardError
2304
+ use_fftw = false
2305
+ break
1313
2306
  end
2307
+ pos += hop
1314
2308
  end
1315
- x
1316
2309
  end
1317
2310
 
1318
- # Overlapping (50%) windowed FFTs, power-averaged (Welch-style)
1319
- hop = [nfft / 2, 1].max
1320
- specs = []
1321
- pos = 0
1322
- while pos + nfft <= iq.length && specs.length < avg
1323
- blk = Array.new(nfft) { |k| iq[pos + k] * hann[k] }
1324
- sp = fft_proc.call(blk)
1325
- half = nfft / 2
1326
- shifted = sp[half, nfft - half] + sp[0, half]
1327
- specs << shifted.map(&:abs2)
1328
- pos += hop
1329
- end
1330
- if specs.empty?
1331
- blk = Array.new(nfft) do |k|
1332
- (k < iq.length ? iq[k] : Complex(0.0, 0.0)) * hann[k]
2311
+ unless use_fftw && !specs.empty?
2312
+ # Pure-Ruby complex FFT fallback (radix-2 Cooley-Tukey).
2313
+ iq = Array.new(n_iq) { |i| Complex(floats[2 * i], floats[(2 * i) + 1]) }
2314
+ log2n = Math.log2(nfft).to_i
2315
+ fft_proc = lambda do |x|
2316
+ n = x.length
2317
+ j = 0
2318
+ (0...(n - 1)).each do |i|
2319
+ x[i], x[j] = x[j], x[i] if i < j
2320
+ k = n >> 1
2321
+ while k <= j
2322
+ j -= k
2323
+ k >>= 1
2324
+ end
2325
+ j += k
2326
+ end
2327
+ (1..log2n).each do |stage|
2328
+ m = 1 << stage
2329
+ half = m >> 1
2330
+ wm = Complex.polar(1.0, -two_pi / m)
2331
+ (0...n).step(m) do |kk|
2332
+ w = Complex(1.0, 0.0)
2333
+ (0...half).each do |jj|
2334
+ t = w * x[kk + jj + half]
2335
+ u = x[kk + jj]
2336
+ x[kk + jj] = u + t
2337
+ x[kk + jj + half] = u - t
2338
+ w *= wm
2339
+ end
2340
+ end
2341
+ end
2342
+ x
2343
+ end
2344
+
2345
+ specs = []
2346
+ pos = 0
2347
+ while pos + nfft <= iq.length && specs.length < avg
2348
+ blk = Array.new(nfft) { |k| iq[pos + k] * hann[k] }
2349
+ sp = fft_proc.call(blk)
2350
+ half = nfft / 2
2351
+ shifted = sp[half, nfft - half] + sp[0, half]
2352
+ specs << shifted.map(&:abs2)
2353
+ pos += hop
2354
+ end
2355
+ if specs.empty?
2356
+ blk = Array.new(nfft) do |k|
2357
+ (k < iq.length ? iq[k] : Complex(0.0, 0.0)) * hann[k]
2358
+ end
2359
+ sp = fft_proc.call(blk)
2360
+ half = nfft / 2
2361
+ shifted = sp[half, nfft - half] + sp[0, half]
2362
+ specs << shifted.map(&:abs2)
1333
2363
  end
1334
- sp = fft_proc.call(blk)
1335
- half = nfft / 2
1336
- shifted = sp[half, nfft - half] + sp[0, half]
1337
- specs << shifted.map(&:abs2)
1338
2364
  end
1339
2365
 
1340
2366
  avg_pwr = Array.new(nfft, 0.0)
@@ -1371,16 +2397,33 @@ module PWN
1371
2397
  db[nfft - 1 - gi] = median_nf
1372
2398
  end
1373
2399
 
1374
- # Noise floor: median of dB (robust; 12th-percentile put nf+6dB ~= mean
1375
- # noise so ~half of noise bins already cleared the height threshold).
2400
+ # Noise floor: median of dB (robust). Callers may supply expected
2401
+ # channel_bw_hz so min_dist tracks real channel spacing (pager_flex
2402
+ # 20 kHz / fm_radio 200 kHz) instead of a fixed 6 kHz heuristic that
2403
+ # over-fragments wide carriers and under-separates dense narrow ones.
1376
2404
  noise_floor = median_nf
1377
-
1378
- # SDRangel-style peak detection: local maxima above noise_floor+10dB with
1379
- # min bin separation and >= 6 dB prominence. Thresholds are relative to
1380
- # the MEDIAN noise floor so avg>=8 yields ~0 false positives on pure noise.
1381
- height_thr = noise_floor + 10.0
1382
- min_dist = [3, (6000.0 / res_hz).to_i].max
1383
- prom_thr = 6.0
2405
+ channel_bw_hz = (opts[:channel_bw_hz] || opts[:plan_bw_hz] || 0).to_f
2406
+ channel_bw_hz = 0.0 if channel_bw_hz.negative?
2407
+
2408
+ # Peak picker (relative thresholds only absolute S-meter dBFS is
2409
+ # meaningless on uncalibrated 10*log10(|FFT|^2)):
2410
+ # height : median_nf + height_db (default 12)
2411
+ # prom : >= prom_thr (default 8)
2412
+ # min_dist : plan-parametric via #fft_plan_geometry — half channel /
2413
+ # raster / step-aware floor, converted to bins by res_hz.
2414
+ # Callers may tighten with opts[:peak_height_db]/[:peak_prom_db]/
2415
+ # opts[:step_hz] (raster) for denser narrowband plans.
2416
+ height_db = (opts[:peak_height_db] || 12.0).to_f
2417
+ prom_thr = (opts[:peak_prom_db] || 8.0).to_f
2418
+ height_thr = noise_floor + height_db
2419
+ step_for_geo = (opts[:step_hz] || opts[:raster_hz] || 0).to_f
2420
+ geo = fft_plan_geometry(
2421
+ plan_bw_hz: channel_bw_hz,
2422
+ step_hz: step_for_geo,
2423
+ res_hz: res_hz
2424
+ )
2425
+ sep_hz = geo[:sep_hz]
2426
+ min_dist = geo[:min_dist_bins]
1384
2427
 
1385
2428
  candidates = []
1386
2429
  (1...(nfft - 1)).each do |i|
@@ -1388,7 +2431,7 @@ module PWN
1388
2431
  next unless db[i] > db[i - 1] && db[i] >= db[i + 1]
1389
2432
 
1390
2433
  # prominence: peak - highest of the two side-valley minima toward the
1391
- # nearest higher-or-equal neighbour (scipy.signal.peak_prominences algorithm)
2434
+ # nearest higher-or-equal neighbour (scipy.signal.peak_prominences)
1392
2435
  left_min = db[i]
1393
2436
  li = i - 1
1394
2437
  while li >= 0 && db[li] <= db[i]
@@ -1415,15 +2458,38 @@ module PWN
1415
2458
  end
1416
2459
  selected.sort_by! { |c| c[:idx] }
1417
2460
 
1418
- edge_thr = noise_floor + 3.5
2461
+ # Occupied-BW edge walk is RELATIVE TO THE PEAK, never to the global
2462
+ # noise floor. Walking down to noise+3.5 dB on a dense paging band
2463
+ # (or any band with elevated continuum / many neighbours) floods the
2464
+ # whole lobe and reports hundreds of kHz of "occupied" bandwidth —
2465
+ # which then breaks min_bw_ratio filtering, merge tolerance, and the
2466
+ # refine window. Use a -6 dB from-peak contour (≈ half-power / 3 dB
2467
+ # each side) with a hard cap of ~2 * channel_bw when known.
2468
+ half_pwr_drop_db = 6.0
2469
+ # Cap occupied-BW walk to ~1 plan_bw each side of peak (plan-parametric).
2470
+ max_half_bins = geo[:max_half_bins]
2471
+
1419
2472
  signals = selected.map do |c|
1420
2473
  p = c[:idx]
2474
+ edge_rel = c[:pwr] - half_pwr_drop_db
1421
2475
  l = p
1422
- l -= 1 while l.positive? && db[l] >= edge_thr
2476
+ l -= 1 while l.positive? && (p - l) < max_half_bins && db[l - 1] >= edge_rel
1423
2477
  r = p
1424
- r += 1 while r < (nfft - 1) && db[r] >= edge_thr
1425
- bw_hz = ([r - l, 1].max * res_hz).to_i
1426
- center = (center_hz + freq_off[p]).to_i
2478
+ r += 1 while r < (nfft - 1) && (r - p) < max_half_bins && db[r + 1] >= edge_rel
2479
+ bw_hz = ([r - l + 1, 1].max * res_hz).to_i
2480
+
2481
+ # Power-weighted centroid over the -6 dB lobe → sub-bin centre that
2482
+ # lands much closer to the true channel than the peak bin alone
2483
+ # (critical for precision-4 / 1 kHz FLEX raster snaps).
2484
+ lin_sum = 0.0
2485
+ mom_sum = 0.0
2486
+ (l..r).each do |bi|
2487
+ lin = 10.0**(db[bi] / 10.0)
2488
+ lin_sum += lin
2489
+ mom_sum += lin * bi
2490
+ end
2491
+ centroid_bin = lin_sum.positive? ? (mom_sum / lin_sum) : p.to_f
2492
+ center = (center_hz + ((centroid_bin - (nfft / 2)) * res_hz)).round
1427
2493
  {
1428
2494
  hz: center,
1429
2495
  freq: PWN::SDR.hz_to_s(freq: center),
@@ -1446,7 +2512,7 @@ module PWN
1446
2512
  nfft: nfft,
1447
2513
  avg: avg,
1448
2514
  resolution_hz: res_hz.round(2),
1449
- samples: iq.length,
2515
+ samples: n_iq,
1450
2516
  capture_secs: capture_secs,
1451
2517
  spectrum: bins_out,
1452
2518
  signals: signals,
@@ -1473,13 +2539,18 @@ module PWN
1473
2539
  # strength_lock: 'optional',
1474
2540
  # min_snr_db: 'optional - Minimum SNR in dB above per-chunk noise floor to report (defaults to 12.0)',
1475
2541
  # precision: 'optional - Band-plan channel raster; detections snapped to 10**(precision-1) Hz grid (defaults to 5)',
1476
- # min_bw_ratio: 'optional - Reject FFT peaks narrower than min_bw_ratio * plan bandwidth as spurs (defaults to 0.30)',
1477
- # demodulator_mode: 'optional - Demodulator mode to attribute to detections (defaults to WFM)',
1478
- # bandwidth: 'optional - Passband bandwidth to attribute to detections (defaults to "200.000")',
1479
- # squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
2542
+ # min_bw_ratio: 'optional - Reject FFT peaks narrower than min_bw_ratio * plan bandwidth as spurs (defaults to 0.0 = off; half-power carrier BW is often << plan BW)',
2543
+ # demodulator_mode: 'optional - Demodulator mode APPLIED to GQRX + attributed to detections (defaults to WFM)',
2544
+ # bandwidth: 'optional - Passband bandwidth APPLIED to GQRX + attributed to detections (defaults to "200.000")',
2545
+ # squelch: 'optional - Squelch level APPLIED to GQRX (defaults to strength_lock - 3.0)',
2546
+ # audio_gain_db: 'optional - Audio gain in dB APPLIED to GQRX (defaults to 0.0)',
2547
+ # rf_gain: 'optional - RF gain APPLIED to GQRX (defaults to 0.0)',
2548
+ # intermediate_gain: 'optional - Intermediate gain APPLIED to GQRX (defaults to 32.0)',
2549
+ # baseband_gain: 'optional - Baseband gain APPLIED to GQRX (defaults to 10.0)',
1480
2550
  # decoder: 'optional - Decoder key (e.g. :gsm) to attribute to detections',
1481
2551
  # location: 'optional - Location string for AI analysis',
1482
- # keep_spectrum: 'optional - if true return raw spectrum arrays too (large)'
2552
+ # keep_spectrum: 'optional - if true return raw spectrum arrays too (large)',
2553
+ # refine: 'optional - After panoramic FFT, re-walk each detection with traditional edge_detection + find_best_peak scoped around the candidate to lock the exact channel frequency (defaults to true)'
1483
2554
  # )
1484
2555
  #
1485
2556
  # Uses chunk-wise retuning where chunk = sample_rate so that the *entire visible band* (waterfall width)
@@ -1502,10 +2573,11 @@ module PWN
1502
2573
 
1503
2574
  sr = (opts[:sample_rate] || 1_000_000).to_i
1504
2575
  nfft = (opts[:nfft] || 2048).to_i
1505
- avgs = (opts[:avg] || 8).to_i
1506
- cap = (opts[:capture_secs] || 0.10).to_f
2576
+ # avg/capture defaults lean fast — lock-hunt refine recovers weak-edge hits.
2577
+ avgs = (opts[:avg] || 6).to_i
2578
+ cap = (opts[:capture_secs] || 0.08).to_f
1507
2579
  strength_lock = (opts[:strength_lock] || -70.0).to_f
1508
- min_snr_db = (opts[:min_snr_db] || 12.0).to_f
2580
+ min_snr_db = (opts[:min_snr_db] || 18.0).to_f
1509
2581
  keep_spec = opts[:keep_spectrum] ? true : false
1510
2582
  res_hz = sr / nfft.to_f
1511
2583
  demodulator_mode = opts[:demodulator_mode] ||= :WFM
@@ -1532,8 +2604,14 @@ module PWN
1532
2604
  step_hz = 10**(precision - 1)
1533
2605
  plan_bw_hz = PWN::SDR.hz_to_i(freq: bandwidth)
1534
2606
  plan_bw_hz = step_hz if plan_bw_hz.zero?
1535
- min_bw_ratio = (opts[:min_bw_ratio] || 0.30).to_f
1536
- min_bw_hz = [(plan_bw_hz * min_bw_ratio).to_i, res_hz.ceil].max
2607
+ # min_bw_ratio is OPTIONAL and OFF by default (0.0). Half-power occupied
2608
+ # width of a real FLEX/POCSAG/NBFM carrier is often << channel assignment
2609
+ # (e.g. ~1–5 kHz vs plan 20 kHz), so a 0.30*plan floor rejected the
2610
+ # actual strongest peaks. Floor is always ≥ 1 FFT bin; callers wanting
2611
+ # spur rejection by width can still pass --min-bw-ratio.
2612
+ min_bw_ratio = (opts[:min_bw_ratio] || 0.0).to_f
2613
+ min_bw_hz = [res_hz.ceil, 1].max
2614
+ min_bw_hz = [min_bw_hz, (plan_bw_hz * min_bw_ratio).to_i].max if min_bw_ratio.positive?
1537
2615
 
1538
2616
  range_str = ranges.map do |rr|
1539
2617
  a = PWN::SDR.hz_to_s(freq: PWN::SDR.hz_to_i(freq: rr[:start_freq]))
@@ -1546,24 +2624,121 @@ module PWN
1546
2624
  detected = []
1547
2625
  all_specs = [] if keep_spec
1548
2626
 
1549
- puts "[FAST-SCAN] band-plan gate: raster=#{PWN::SDR.hz_to_s(freq: step_hz)} Hz (precision #{precision}), " \
1550
- "plan_bw=#{PWN::SDR.hz_to_s(freq: plan_bw_hz)} Hz, min_occupied_bw>=#{PWN::SDR.hz_to_s(freq: min_bw_hz)} Hz, " \
1551
- "min_snr>=#{min_snr_db} dB"
2627
+ # ---- Panoramic capture radio setup --------------------------------
2628
+ # CRITICAL: GQRX IQRECORD dumps the *demodulator IF* stream, not the
2629
+ # raw ADC. Forcing the band-plan demod/passband here (e.g. FM / 20 kHz
2630
+ # for pager_flex) collapsed the panoramic view to a 20 kHz keyhole and
2631
+ # destroyed edge/BW detection. Capture always uses RAW with a passband
2632
+ # equal to sample_rate so each chunk really spans `sr` Hz. Band-plan
2633
+ # demod/bandwidth/squelch are applied later (during refine / analyze)
2634
+ # and still attributed onto every detection hash for decoder re-tunes.
2635
+ mode_str = demodulator_mode.to_s.upcase
2636
+ passband_hz = plan_bw_hz # decoder IF — applied only in refine phase
2637
+ capture_mode = 'RAW'
2638
+ capture_passband_hz = sr
2639
+ audio_gain_db = (opts[:audio_gain_db] || 0.0).to_f
2640
+ rf_gain = (opts[:rf_gain] || 0.0).to_f
2641
+ intermediate_gain = (opts[:intermediate_gain] || 32.0).to_f
2642
+ baseband_gain = (opts[:baseband_gain] || 10.0).to_f
2643
+ user_strength_lock = !opts[:strength_lock].nil? # honour explicit -S (driver may pass key:nil)
2644
+
2645
+ puts '-' * 86
2646
+ puts '[FAST-SCAN] SESSION PARAMS >> panoramic capture + deferred band-plan IF:'
2647
+ puts " capture mode : #{capture_mode} #{PWN::SDR.hz_to_s(freq: capture_passband_hz)} Hz (full sample_rate span)"
2648
+ puts " band-plan mode : #{mode_str} #{PWN::SDR.hz_to_s(freq: passband_hz)} Hz (applied at refine / decoder)"
2649
+ puts " squelch (L SQL) : #{squelch} dBFS"
2650
+ puts " strength_lock : #{strength_lock} dBFS (S-meter edge gate; auto-calibrated unless -S given)"
2651
+ puts " audio_gain (AF) : #{audio_gain_db} dB"
2652
+ puts " rf_gain : #{rf_gain}"
2653
+ puts " intermediate_gain: #{intermediate_gain}"
2654
+ puts " baseband_gain : #{baseband_gain}"
2655
+ puts " decoder : #{decoder.inspect}"
2656
+ puts " raster/precision : #{PWN::SDR.hz_to_s(freq: step_hz)} Hz (precision #{precision})"
2657
+ puts " plan_bw/min_bw : #{PWN::SDR.hz_to_s(freq: plan_bw_hz)} Hz / min occupied >= #{PWN::SDR.hz_to_s(freq: min_bw_hz)} Hz (ratio #{min_bw_ratio})"
2658
+ puts " min_snr : #{min_snr_db} dB (FFT scale, not S-meter)"
2659
+ puts " sample_rate/nfft : #{sr} SPS / #{nfft}"
2660
+ puts '-' * 86
2661
+
2662
+ # Floor squelch for capture so we never mute I/Q while sweeping.
2663
+ cmd(
2664
+ gqrx_sock: gqrx_sock,
2665
+ cmd: 'L SQL -150.0',
2666
+ resp_ok: 'RPRT 0'
2667
+ )
2668
+
2669
+ # Disable RDS during the panoramic scan.
2670
+ begin
2671
+ cmd(gqrx_sock: gqrx_sock, cmd: 'U RDS 0', resp_ok: 'RPRT 0')
2672
+ rescue StandardError
2673
+ nil
2674
+ end
2675
+
2676
+ # RAW + sample_rate passband = full panoramic IF for IQRECORD.
2677
+ cmd(
2678
+ gqrx_sock: gqrx_sock,
2679
+ cmd: "M #{capture_mode} #{capture_passband_hz}",
2680
+ resp_ok: 'RPRT 0'
2681
+ )
2682
+
2683
+ cmd(
2684
+ gqrx_sock: gqrx_sock,
2685
+ cmd: "L AF #{audio_gain_db}",
2686
+ resp_ok: 'RPRT 0'
2687
+ )
2688
+
2689
+ cmd(
2690
+ gqrx_sock: gqrx_sock,
2691
+ cmd: "L RF_GAIN #{rf_gain}",
2692
+ resp_ok: 'RPRT 0'
2693
+ )
2694
+
2695
+ cmd(
2696
+ gqrx_sock: gqrx_sock,
2697
+ cmd: "L IF_GAIN #{intermediate_gain}",
2698
+ resp_ok: 'RPRT 0'
2699
+ )
2700
+
2701
+ cmd(
2702
+ gqrx_sock: gqrx_sock,
2703
+ cmd: "L BB_GAIN #{baseband_gain}",
2704
+ resp_ok: 'RPRT 0'
2705
+ )
2706
+
2707
+ # Brief settle so the backend reconfigures IF filter around RAW span.
2708
+ sleep 0.20
2709
+
2710
+ begin
2711
+ applied_mode = cmd(gqrx_sock: gqrx_sock, cmd: 'm').to_s.strip
2712
+ puts "[FAST-SCAN] GQRX capture mode/passband='#{applied_mode}' (expect RAW #{capture_passband_hz})"
2713
+ rescue StandardError => e
2714
+ puts "[FAST-SCAN] WARNING: could not read back mode (#{e.class}: #{e.message})"
2715
+ end
1552
2716
 
1553
2717
  ranges.each do |r|
1554
2718
  s_hz = PWN::SDR.hz_to_i(freq: r[:start_freq])
1555
2719
  t_hz = PWN::SDR.hz_to_i(freq: r[:target_freq])
1556
2720
  dir = t_hz >= s_hz ? 1 : -1
1557
- step = (sr * 0.85).to_i # chunk step - use 85% to have overlap / avoid edge artifacts
1558
- step = sr if step < 100_000
1559
-
1560
- puts "[FAST-SCAN] Panoramic covering #{PWN::SDR.hz_to_s(freq: s_hz)}..#{PWN::SDR.hz_to_s(freq: t_hz)} using #{sr} SPS chunks (step #{PWN::SDR.hz_to_s(freq: step)})"
2721
+ # Soft chunk-edge guard (also applied post-snapshot). Cap so a
2722
+ # wide plan (fm_radio 200 kHz) at modest sample_rate (1 Msps) does
2723
+ # not eat the entire usable span.
2724
+ edge_floor = [plan_bw_hz, 2 * step_hz, 1_000].max
2725
+ edge_floor = [edge_floor, (sr * 0.12).to_i].min # never >12% of span
2726
+ edge_guard_hz = [(sr * 0.05).to_i, edge_floor].max
2727
+ usable = sr - (2 * edge_guard_hz)
2728
+ usable = [usable, (sr * 0.50).to_i].max
2729
+ # Step so successive usable regions OVERLAP by ~15% (was fixed
2730
+ # 0.85*sr, which for FM left ~350 kHz holes per chunk at 1 Msps).
2731
+ step = (usable * 0.85).to_i
2732
+ step = [step, step_hz, 50_000].max
2733
+ step = sr if step > sr
2734
+
2735
+ puts "[FAST-SCAN] Panoramic covering #{PWN::SDR.hz_to_s(freq: s_hz)}..#{PWN::SDR.hz_to_s(freq: t_hz)} using #{sr} SPS chunks (step #{PWN::SDR.hz_to_s(freq: step)}, edge_guard=#{PWN::SDR.hz_to_s(freq: edge_guard_hz)}, usable≈#{PWN::SDR.hz_to_s(freq: usable)})"
1561
2736
 
1562
2737
  h = s_hz
1563
2738
  while dir.positive? ? (h <= t_hz) : (h >= t_hz)
1564
2739
  # retune to put this chunk in the visible IF
1565
2740
  tune_to(gqrx_sock: gqrx_sock, hz: h)
1566
- sleep 0.15 # allow GQRX / SDR to settle the IF filter & AGC etc.
2741
+ sleep 0.08 # allow GQRX / SDR to settle the IF filter & AGC etc.
1567
2742
 
1568
2743
  snap = get_spectrum_snapshot(
1569
2744
  gqrx_sock: gqrx_sock,
@@ -1572,7 +2747,9 @@ module PWN
1572
2747
  nfft: nfft,
1573
2748
  avg: avgs,
1574
2749
  capture_secs: cap,
1575
- strength_offset_db: opts[:strength_offset_db]
2750
+ strength_offset_db: opts[:strength_offset_db],
2751
+ channel_bw_hz: plan_bw_hz,
2752
+ step_hz: step_hz
1576
2753
  )
1577
2754
 
1578
2755
  all_specs << snap if keep_spec
@@ -1583,12 +2760,34 @@ module PWN
1583
2760
  # against the -70 dBFS S-meter strength_lock without a
1584
2761
  # user-supplied strength_offset_db. NO fallback: a quiet chunk
1585
2762
  # correctly contributes zero detections.
2763
+ # Upper BW bound: half-power estimate should not exceed ~2× plan.
2764
+ # Stops continuum / multi-carrier blobs from surviving as one "signal".
2765
+ max_bw_hz = [(plan_bw_hz * 2), (step_hz * 8), min_bw_hz].max
2766
+ # edge_guard_hz / usable computed once per range above so the
2767
+ # step and post-snapshot acceptance region stay consistent.
2768
+ chunk_lo = h - (sr / 2) + edge_guard_hz
2769
+ chunk_hi = h + (sr / 2) - edge_guard_hz
2770
+ # Also clamp to the requested scan range.
2771
+ range_lo = [s_hz, t_hz].min
2772
+ range_hi = [s_hz, t_hz].max
2773
+
1586
2774
  sigs.each do |sig|
1587
2775
  next if sig[:snr_db] && sig[:snr_db] < min_snr_db
1588
- next if sig[:power_db] && sig[:power_db] < strength_lock
1589
- # Band-plan width gate: reject spurs / pilots / IMD narrower
1590
- # than a plausible fraction of the expected channel bandwidth.
2776
+
2777
+ raw_candidate_hz = (sig[:hz] || sig[:freq_hz]).to_i
2778
+ next if raw_candidate_hz < range_lo || raw_candidate_hz > range_hi
2779
+ next if raw_candidate_hz < chunk_lo || raw_candidate_hz > chunk_hi
2780
+ # Require decent prominence when present (noise continuum has low prom).
2781
+ next if sig[:prominence_db] && sig[:prominence_db].to_f < 8.0
2782
+
2783
+ # NOTE: strength_lock is an S-meter (dBFS) gate. FFT power_db is
2784
+ # uncalibrated 10*log10(|X|^2) and MUST NOT be compared to it
2785
+ # unless the caller supplied strength_offset_db. Skip that gate
2786
+ # for panoramic detections — min_snr_db is the correct filter.
2787
+ next if opts[:strength_offset_db] && sig[:power_db] && sig[:power_db] < strength_lock
2788
+ # Band-plan width gates (relative half-power BW estimator).
1591
2789
  next if sig[:bw_hz] && sig[:bw_hz] < min_bw_hz
2790
+ next if sig[:bw_hz] && sig[:bw_hz] > max_bw_hz
1592
2791
 
1593
2792
  raw_hz = sig[:hz] || sig[:freq_hz]
1594
2793
  # Snap to the band-plan channel raster so the same emitter seen
@@ -1602,17 +2801,21 @@ module PWN
1602
2801
  freq: PWN::SDR.hz_to_s(freq: hz),
1603
2802
  demodulator_mode: demodulator_mode,
1604
2803
  bandwidth: bandwidth,
1605
- strength_db: sig[:power_db],
2804
+ strength_db: sig[:power_db].to_f.round(2),
1606
2805
  decoder: decoder,
1607
2806
  squelch: squelch,
1608
2807
  strength_lock: strength_lock,
2808
+ audio_gain_db: audio_gain_db,
2809
+ rf_gain: rf_gain,
2810
+ intermediate_gain: intermediate_gain,
2811
+ baseband_gain: baseband_gain,
1609
2812
  iteration: 1,
1610
2813
  hz: hz,
1611
- raw_peak_hz: raw_hz,
1612
- bw_hz: sig[:bw_hz],
1613
- snr_db: sig[:snr_db],
1614
- prominence_db: sig[:prominence_db],
1615
- noise_floor_db: sig[:noise_floor_db],
2814
+ raw_peak_hz: raw_hz.to_i,
2815
+ bw_hz: sig[:bw_hz].to_i,
2816
+ snr_db: sig[:snr_db].to_f.round(2),
2817
+ prominence_db: sig[:prominence_db].to_f.round(2),
2818
+ noise_floor_db: sig[:noise_floor_db].to_f.round(2),
1616
2819
  chunk_center: PWN::SDR.hz_to_s(freq: h),
1617
2820
  method: :fast_spectrum_sdrangel_like
1618
2821
  }
@@ -1629,12 +2832,12 @@ module PWN
1629
2832
  merged = []
1630
2833
  detected.each do |d|
1631
2834
  prev = merged.last
1632
- tol = [
1633
- (plan_bw_hz / 2),
1634
- step_hz,
1635
- (d[:bw_hz].to_i / 2),
1636
- (res_hz * 2).ceil
1637
- ].max
2835
+ tol = fft_plan_geometry(
2836
+ plan_bw_hz: plan_bw_hz,
2837
+ step_hz: step_hz,
2838
+ res_hz: res_hz,
2839
+ measured_bw_hz: d[:bw_hz].to_i
2840
+ )[:merge_tol_hz]
1638
2841
  if prev && (d[:hz] - prev[:hz]).abs <= tol
1639
2842
  merged[-1] = d if (d[:snr_db] || -999) > (prev[:snr_db] || -999)
1640
2843
  else
@@ -1643,36 +2846,153 @@ module PWN
1643
2846
  end
1644
2847
  detected = merged
1645
2848
 
1646
- # Attach AI analysis per detection AFTER merge so we only spend
1647
- # inference on the deduplicated set (matches #scan_range behaviour).
2849
+ # ---- Exact-channel refine pass ------------------------------------
2850
+ # Preliminary FFT peaks are only as precise as the bin resolution
2851
+ # (sample_rate/nfft) and the band-plan raster snap. For decoding we
2852
+ # want the true channel centre, so re-walk each survivor with the
2853
+ # traditional S-meter edge_detection + find_best_peak pipeline,
2854
+ # scoped to a tight window around the FFT estimate. Opt-out via
2855
+ # refine: false for pure-panorama speed.
2856
+ refine = opts.fetch(:refine, true)
2857
+ if refine && !detected.empty?
2858
+ # Switch GQRX into the band-plan demod/passband BEFORE S-meter
2859
+ # walks — edge_detection / find_best_peak read l STRENGTH which is
2860
+ # only meaningful inside the decoder IF (FM 20 kHz for FLEX, etc.).
2861
+ begin
2862
+ cmd(
2863
+ gqrx_sock: gqrx_sock,
2864
+ cmd: "M #{mode_str} #{passband_hz}",
2865
+ resp_ok: 'RPRT 0'
2866
+ )
2867
+ cmd(
2868
+ gqrx_sock: gqrx_sock,
2869
+ cmd: "L SQL #{squelch}",
2870
+ resp_ok: 'RPRT 0'
2871
+ )
2872
+ sleep 0.15
2873
+ rescue StandardError => e
2874
+ puts "[FAST-SCAN] WARNING: could not apply band-plan IF for refine (#{e.class}: #{e.message})"
2875
+ end
2876
+
2877
+ # Auto-calibrate strength_lock against the *live* S-meter noise floor
2878
+ # unless the user passed an explicit -S/--strength-lock. The default
2879
+ # -70 dBFS is meaningless across SDRs / bands (live FLEX band here
2880
+ # sits at ≈ -55…-83 dBFS); without this, edge walks either flood the
2881
+ # entire band or refuse to engage at all.
2882
+ unless user_strength_lock
2883
+ seed_hz_for_nf = detected.map { |d| d[:hz].to_i }.reject(&:zero?)
2884
+ seed_hz_for_nf = [PWN::SDR.hz_to_i(freq: ranges.first[:start_freq])] if seed_hz_for_nf.empty?
2885
+ nf_samples = []
2886
+ seed_hz_for_nf.first(5).each do |hz|
2887
+ # Probe a few offsets off-channel to estimate quiet floor.
2888
+ [-3 * plan_bw_hz, -plan_bw_hz, plan_bw_hz, 3 * plan_bw_hz].each do |off|
2889
+ ph = hz + off
2890
+ next if ph <= 0
2891
+
2892
+ tune_to(gqrx_sock: gqrx_sock, hz: ph)
2893
+ 3.times do
2894
+ nf_samples << cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
2895
+ sleep 0.02
2896
+ end
2897
+ end
2898
+ end
2899
+ if nf_samples.any?
2900
+ nf_samples.sort!
2901
+ live_nf = nf_samples[nf_samples.length / 2] # median
2902
+ auto_lock = (live_nf + 8.0).round(1)
2903
+ puts "[FAST-SCAN] auto strength_lock: live S-meter nf≈#{live_nf.round(1)} dBFS → lock=#{auto_lock} dBFS (was #{strength_lock})"
2904
+ strength_lock = auto_lock
2905
+ # Keep squelch a few dB under the lock for any later decoder use.
2906
+ squelch = strength_lock - 3.0 if squelch >= strength_lock || opts[:squelch].nil?
2907
+ detected.each do |d|
2908
+ d[:strength_lock] = strength_lock
2909
+ d[:squelch] = squelch
2910
+ end
2911
+ end
2912
+ end
2913
+
2914
+ # Default refine is lock-hunt (S-meter probe ±N raster steps) —
2915
+ # ~5–10× faster than local-FFT zoom and more accurate on FM where
2916
+ # panoramic centroids often land one channel off. Pass
2917
+ # refine_mode: :fft for the legacy high-res FFT zoom, or
2918
+ # refine_mode: :hybrid to run both.
2919
+ refine_mode = opts.fetch(:refine_mode, :lock)
2920
+ detected = refine_detections(
2921
+ gqrx_sock: gqrx_sock,
2922
+ detections: detected,
2923
+ precision: precision,
2924
+ step_hz: step_hz,
2925
+ strength_lock: strength_lock,
2926
+ plan_bw_hz: plan_bw_hz,
2927
+ demodulator_mode: demodulator_mode,
2928
+ bandwidth: bandwidth,
2929
+ squelch: squelch,
2930
+ refine_mode: refine_mode
2931
+ )
2932
+ elsif !refine
2933
+ puts '[FAST-SCAN] refine:false — skipping iterative edge/peak refinement'
2934
+ end
2935
+
2936
+ # Final in-range gate (refine can drift a little past the requested
2937
+ # edges via local zoom). Drop anything outside the union of ranges.
2938
+ if detected.any?
2939
+ global_lo = ranges.map { |rr| PWN::SDR.hz_to_i(freq: rr[:start_freq]) }.min
2940
+ global_hi = ranges.map { |rr| PWN::SDR.hz_to_i(freq: rr[:target_freq]) }.max
2941
+ before = detected.length
2942
+ detected.select! { |d| d[:hz].to_i.between?(global_lo, global_hi) }
2943
+ puts "[FAST-SCAN] dropped #{before - detected.length} out-of-range detection(s) after refine" if detected.length != before
2944
+ end
2945
+
2946
+ # Attach AI analysis only when explicitly requested. LLM calls per
2947
+ # detection dominate wall-clock on dense bands (fm_radio ~25 hits) and
2948
+ # defeat the point of panoramic/FFT scanning. Opt-in via ai_analysis:true
2949
+ # (iterative #scan_range still does AI by default for parity with its
2950
+ # slower, fewer-hit flow).
2951
+ do_ai = opts[:ai_analysis] ? true : false
1648
2952
  detected.each do |freq_obj|
1649
2953
  puts "\n**** Detected Signal ****"
1650
- ai_analysis = PWN::AI::Agent::GQRX.analyze(
1651
- request: freq_obj.to_json,
1652
- location: location
1653
- )
1654
- freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
2954
+ if do_ai
2955
+ begin
2956
+ ai_analysis = PWN::AI::Agent::GQRX.analyze(
2957
+ request: freq_obj.to_json,
2958
+ location: location
2959
+ )
2960
+ freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
2961
+ rescue StandardError
2962
+ # AI analysis is best-effort; never let it kill the scan.
2963
+ nil
2964
+ end
2965
+ end
1655
2966
  puts JSON.pretty_generate(freq_obj)
1656
2967
  puts '-' * 86
1657
- rescue StandardError
1658
- # AI analysis is best-effort; never let it kill the scan.
1659
- puts JSON.pretty_generate(freq_obj)
1660
2968
  end
1661
2969
 
1662
- resp = log_signals(
2970
+ meta = {
2971
+ sample_rate_used: sr,
2972
+ nfft: nfft,
2973
+ precision: precision,
2974
+ plan_bw_hz: plan_bw_hz,
2975
+ demodulator_mode: demodulator_mode,
2976
+ bandwidth: bandwidth,
2977
+ squelch: squelch,
2978
+ strength_lock: strength_lock,
2979
+ audio_gain_db: audio_gain_db,
2980
+ rf_gain: rf_gain,
2981
+ intermediate_gain: intermediate_gain,
2982
+ baseband_gain: baseband_gain,
2983
+ decoder: decoder,
2984
+ method: :fast_scan_range
2985
+ }
2986
+ meta[:spectrums] = all_specs if keep_spec
2987
+
2988
+ # Single write through log_signals so top-level keys always match
2989
+ # the iterative #scan_range schema (plus FFT-only provenance).
2990
+ log_signals(
1663
2991
  signals_detected: detected,
1664
2992
  timestamp_start: ts_start,
1665
- scan_log: scan_log
2993
+ scan_log: scan_log,
2994
+ meta: meta
1666
2995
  )
1667
- resp[:spectrums] = all_specs if keep_spec
1668
- resp[:sample_rate_used] = sr
1669
- resp[:nfft] = nfft
1670
- resp[:precision] = precision
1671
- resp[:plan_bw_hz] = plan_bw_hz
1672
- resp[:method] = :fast_scan_range
1673
- File.write(scan_log, JSON.pretty_generate(resp))
1674
- File.write(scan_log, "\n", mode: 'a')
1675
- resp
1676
2996
  rescue StandardError => e
1677
2997
  raise e
1678
2998
  end
@@ -1705,7 +3025,9 @@ module PWN
1705
3025
  precision: 'optional - Frequency step precision (number of digits; defaults to 6)',
1706
3026
  demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
1707
3027
  bandwidth: 'optional - Bandwidth (defaults to "200.000")',
1708
- decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
3028
+ decoder: 'optional - Decoder key (e.g., :gsm / :rds) to start live decoding',
3029
+ interactive: 'optional - false → decoder.sample Hash (default true = TTY decode)',
3030
+ settle_secs: 'optional - seconds for decoder.sample (RDS default 8)',
1709
3031
  suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
1710
3032
  keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
1711
3033
  )
@@ -1747,13 +3069,18 @@ module PWN
1747
3069
  strength_lock: 'optional - Minimum signal strength in dBFS to report (defaults to -70.0; only meaningful with strength_offset_db calibration)',
1748
3070
  min_snr_db: 'optional - Minimum SNR in dB above per-chunk noise floor to report (defaults to 12.0)',
1749
3071
  precision: 'optional - Band-plan channel raster; detections snapped to 10**(precision-1) Hz grid (defaults to 5)',
1750
- min_bw_ratio: 'optional - Reject FFT peaks narrower than min_bw_ratio * plan bandwidth as spurs (defaults to 0.30)',
1751
- demodulator_mode: 'optional - Demodulator mode to attribute to detections (defaults to WFM)',
1752
- bandwidth: 'optional - Passband bandwidth (defaults to "200.000")',
1753
- squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
3072
+ min_bw_ratio: 'optional - Reject FFT peaks narrower than min_bw_ratio * plan bandwidth as spurs (defaults to 0.0 = off; half-power carrier BW is often << plan BW)',
3073
+ demodulator_mode: 'optional - Demodulator mode APPLIED to GQRX + attributed to detections (defaults to WFM)',
3074
+ bandwidth: 'optional - Passband bandwidth APPLIED to GQRX + attributed (defaults to "200.000")',
3075
+ squelch: 'optional - Squelch level in dBFS APPLIED to GQRX (defaults to strength_lock - 3.0)',
3076
+ audio_gain_db: 'optional - Audio gain in dB APPLIED to GQRX (defaults to 0.0)',
3077
+ rf_gain: 'optional - RF gain APPLIED to GQRX (defaults to 0.0)',
3078
+ intermediate_gain: 'optional - Intermediate gain APPLIED to GQRX (defaults to 32.0)',
3079
+ baseband_gain: 'optional - Baseband gain APPLIED to GQRX (defaults to 10.0)',
1754
3080
  decoder: 'optional - Decoder key (e.g. :gsm) to attribute to each detection',
1755
3081
  location: 'optional - Location string to include in AI analysis',
1756
3082
  keep_spectrum: 'optional - If true, include full spectrum data in result (can be large, defaults to false)',
3083
+ refine: 'optional - After panoramic FFT, re-walk each detection with traditional edge_detection + find_best_peak scoped around the candidate to lock the exact channel frequency (defaults to true)',
1757
3084
  strength_offset_db: 'optional - Add this many dB to all power levels (defaults to 0.0)',
1758
3085
  scan_log: 'optional - Path to save detected signals log (defaults to /tmp/pwn_sdr_gqrx_scan_<start_freq>-<target_freq>_<timestamp>.json)'
1759
3086
  )
@@ -1788,6 +3115,27 @@ module PWN
1788
3115
  iq_raw_file: 'required - iq_raw_file returned from #record method'
1789
3116
  )
1790
3117
 
3118
+ #{self}.read_input_config(
3119
+ path: 'optional - GQRX conf (defaults to ~/.config/gqrx/default.conf)'
3120
+ )
3121
+
3122
+ #{self}.device_input_rates(
3123
+ device: 'optional - SoapySDR device args (defaults to conf [input] device=)'
3124
+ )
3125
+
3126
+ #{self}.set_input_rate(
3127
+ input_rate: 'required - Input rate Hz (rewrites conf [input] sample_rate=)',
3128
+ path: 'optional - GQRX conf path',
3129
+ clamp: 'optional - Snap to nearest device-legal rate (default true)',
3130
+ restart: 'optional - Kill+respawn gqrx so rate takes effect (default false)'
3131
+ )
3132
+
3133
+ #{self}.apply_band_plan_input_rate(
3134
+ band_plan: 'required - e.g. :fm_radio / :ads_b1090 (reads FrequencyAllocation input_rate)',
3135
+ clamp: 'optional - Snap to device-legal rate (default true)',
3136
+ restart: 'optional - Bounce GQRX after write (default false)'
3137
+ )
3138
+
1791
3139
  #{self}.disconnect(
1792
3140
  gqrx_sock: 'required - GQRX socket object returned from #connect method'
1793
3141
  )