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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -2
- data/Gemfile +1 -1
- data/README.md +7 -1
- data/bin/pwn_gqrx_scanner +78 -9
- data/documentation/Agent-Tool-Registry.md +3 -3
- data/documentation/Configuration.md +301 -47
- data/documentation/Cron.md +5 -3
- data/documentation/Diagrams.md +1 -1
- data/documentation/Extrospection.md +472 -26
- data/documentation/FFI.md +72 -8
- data/documentation/Home.md +4 -4
- data/documentation/How-PWN-Works.md +7 -3
- data/documentation/Mistakes.md +9 -0
- data/documentation/SDR.md +83 -10
- data/documentation/Skills-Memory-Learning.md +101 -11
- data/documentation/diagrams/agent-tool-registry.svg +56 -55
- data/documentation/diagrams/ai-integration-tool-calling.svg +24 -24
- data/documentation/diagrams/aws-cloud-security.svg +25 -25
- data/documentation/diagrams/burp-vs-zap-preference.svg +12 -12
- data/documentation/diagrams/code-scanning-sast.svg +25 -25
- data/documentation/diagrams/cron-scheduling.svg +23 -23
- data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +33 -6
- data/documentation/diagrams/dot/hardware-hacking.dot +3 -1
- data/documentation/diagrams/dot/memory-skills-detailed.dot +4 -2
- data/documentation/diagrams/dot/mistakes-negative-feedback.dot +4 -2
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +3 -3
- data/documentation/diagrams/dot/persistence-filesystem.dot +3 -3
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +10 -4
- data/documentation/diagrams/dot/sdr-radio-flow.dot +39 -16
- data/documentation/diagrams/driver-framework.svg +13 -13
- data/documentation/diagrams/extrospection-world-awareness.svg +383 -177
- data/documentation/diagrams/fuzzing-workflow.svg +24 -24
- data/documentation/diagrams/hardware-hacking.svg +84 -69
- data/documentation/diagrams/history-to-drivers.svg +18 -18
- data/documentation/diagrams/memory-skills-detailed.svg +137 -120
- data/documentation/diagrams/mistakes-negative-feedback.svg +152 -137
- data/documentation/diagrams/network-infra-testing.svg +27 -27
- data/documentation/diagrams/overall-pwn-architecture.svg +97 -97
- data/documentation/diagrams/penetration-testing-workflow.svg +30 -30
- data/documentation/diagrams/persistence-filesystem.svg +100 -94
- data/documentation/diagrams/plugin-ecosystem.svg +35 -35
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +254 -207
- data/documentation/diagrams/pwn-repl-prototyping.svg +20 -20
- data/documentation/diagrams/reporting-pipeline.svg +18 -18
- data/documentation/diagrams/reverse-engineering-flow.svg +21 -21
- data/documentation/diagrams/sdr-radio-flow.svg +223 -91
- data/documentation/diagrams/sessions-cron-automation.svg +18 -18
- data/documentation/diagrams/swarm-multi-agent.svg +39 -39
- data/documentation/diagrams/web-application-testing.svg +26 -26
- data/documentation/diagrams/zero-day-research-flow.svg +25 -25
- data/documentation/pwn-ai-Agent.md +16 -10
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/lib/pwn/ai/agent/assembly.rb +1 -1
- data/lib/pwn/ai/agent/btc.rb +1 -1
- data/lib/pwn/ai/agent/burp_suite.rb +1 -1
- data/lib/pwn/ai/agent/extrospection.rb +2562 -57
- data/lib/pwn/ai/agent/gqrx.rb +2 -2
- data/lib/pwn/ai/agent/hacker_one.rb +1 -1
- data/lib/pwn/ai/agent/learning.rb +16 -16
- data/lib/pwn/ai/agent/loop.rb +1 -1
- data/lib/pwn/ai/agent/{introspection.rb → reflect.rb} +12 -12
- data/lib/pwn/ai/agent/sast.rb +1 -1
- data/lib/pwn/ai/agent/tools/extrospection.rb +311 -8
- data/lib/pwn/ai/agent/tools/learning.rb +7 -7
- data/lib/pwn/ai/agent/transparent_browser.rb +1 -1
- data/lib/pwn/ai/agent/vuln_gen.rb +2 -2
- data/lib/pwn/ai/agent.rb +1 -1
- data/lib/pwn/ai/open_ai.rb +10 -6
- data/lib/pwn/config.rb +5 -4
- data/lib/pwn/ffi/adalm_pluto.rb +498 -0
- data/lib/pwn/ffi/fftw.rb +192 -0
- data/lib/pwn/ffi/hack_rf.rb +193 -0
- data/lib/pwn/ffi/liquid.rb +244 -0
- data/lib/pwn/ffi/rtl_sdr.rb +210 -0
- data/lib/pwn/ffi/soapy_sdr.rb +177 -0
- data/lib/pwn/ffi/stdio.rb +9 -1
- data/lib/pwn/ffi/volk.rb +241 -0
- data/lib/pwn/ffi.rb +45 -2
- data/lib/pwn/plugins/burp_suite.rb +2 -2
- data/lib/pwn/sast/pom_version.rb +2 -2
- data/lib/pwn/sast/test_case_engine.rb +1 -1
- data/lib/pwn/sdr/decoder/adsb.rb +201 -12
- data/lib/pwn/sdr/decoder/apt.rb +19 -5
- data/lib/pwn/sdr/decoder/base.rb +314 -1
- data/lib/pwn/sdr/decoder/bluetooth.rb +91 -18
- data/lib/pwn/sdr/decoder/dect.rb +81 -23
- data/lib/pwn/sdr/decoder/dsp.rb +229 -15
- data/lib/pwn/sdr/decoder/flex.rb +23 -6
- data/lib/pwn/sdr/decoder/gps.rb +80 -18
- data/lib/pwn/sdr/decoder/gsm.rb +83 -28
- data/lib/pwn/sdr/decoder/iridium.rb +80 -18
- data/lib/pwn/sdr/decoder/lora.rb +84 -28
- data/lib/pwn/sdr/decoder/lte.rb +80 -18
- data/lib/pwn/sdr/decoder/morse.rb +23 -6
- data/lib/pwn/sdr/decoder/p25.rb +90 -31
- data/lib/pwn/sdr/decoder/pager.rb +23 -6
- data/lib/pwn/sdr/decoder/pocsag.rb +23 -6
- data/lib/pwn/sdr/decoder/rds.rb +219 -52
- data/lib/pwn/sdr/decoder/rfid.rb +81 -27
- data/lib/pwn/sdr/decoder/rtl433.rb +85 -27
- data/lib/pwn/sdr/decoder/rtty.rb +23 -6
- data/lib/pwn/sdr/decoder/wifi.rb +86 -32
- data/lib/pwn/sdr/decoder/zigbee.rb +88 -35
- data/lib/pwn/sdr/decoder.rb +6 -5
- data/lib/pwn/sdr/frequency_allocation.rb +124 -81
- data/lib/pwn/sdr/gqrx.rb +1513 -165
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +96 -0
- data/spec/lib/pwn/ai/agent/{introspection_spec.rb → reflect_spec.rb} +3 -3
- data/spec/lib/pwn/ai/agent/tools/extrospection_spec.rb +8 -24
- data/spec/lib/pwn/ffi/adalm_pluto_spec.rb +37 -0
- data/spec/lib/pwn/ffi/fftw_spec.rb +26 -0
- data/spec/lib/pwn/ffi/hack_rf_spec.rb +25 -0
- data/spec/lib/pwn/ffi/liquid_spec.rb +42 -0
- data/spec/lib/pwn/ffi/rtl_sdr_spec.rb +24 -0
- data/spec/lib/pwn/ffi/soapy_sdr_spec.rb +25 -0
- data/spec/lib/pwn/ffi/volk_spec.rb +36 -0
- data/spec/lib/pwn/sdr/decoder/base_iq_spec.rb +65 -0
- data/spec/lib/pwn/sdr/decoder/dsp_spec.rb +50 -4
- data/spec/lib/pwn/sdr/decoder/rds_spec.rb +45 -4
- data/third_party/pwn_rdoc.jsonl +279 -30
- 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
|
-
#
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
549
|
-
#
|
|
550
|
-
#
|
|
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.
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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-
|
|
809
|
-
puts "\s\s-
|
|
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
|
|
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
|
-
#
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1526
|
+
prev_freq_obj[:strength_db] = best_strength_db.round(2)
|
|
946
1527
|
prev_freq_obj[:iteration] = iteration_total
|
|
947
1528
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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.
|
|
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
|
-
# ----
|
|
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
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
|
1375
|
-
#
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
#
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
prom_thr
|
|
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
|
|
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
|
-
|
|
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] >=
|
|
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] >=
|
|
1425
|
-
bw_hz = ([r - l, 1].max * res_hz).to_i
|
|
1426
|
-
|
|
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:
|
|
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.
|
|
1477
|
-
# demodulator_mode: 'optional - Demodulator mode to
|
|
1478
|
-
# bandwidth: 'optional - Passband bandwidth to
|
|
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
|
-
|
|
1506
|
-
|
|
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] ||
|
|
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
|
|
1536
|
-
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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.
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
1634
|
-
step_hz,
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
]
|
|
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
|
-
#
|
|
1647
|
-
#
|
|
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
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1751
|
-
demodulator_mode: 'optional - Demodulator mode to
|
|
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
|
)
|