pwn 0.5.515 → 0.5.517

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.
data/lib/pwn/sdr/gqrx.rb CHANGED
@@ -7,29 +7,6 @@ module PWN
7
7
  module SDR
8
8
  # This plugin interacts with the remote control interface of GQRX.
9
9
  module GQRX
10
- # Monkey patches for frequency handling
11
- String.class_eval do
12
- def cast_to_raw_hz
13
- gsub('.', '').to_i
14
- end
15
- end
16
-
17
- Integer.class_eval do
18
- # Should always return format of X.XXX.XXX.XXX
19
- # So 002_450_000_000 becomes 2.450.000.000
20
- # So 2_450_000_000 becomes 2.450.000.000
21
- # So 960_000_000 becomes 960.000.000
22
- # 1000 should be 1.000
23
- def cast_to_pretty_hz
24
- str_hz = to_s
25
- # Nuke leading zeros
26
- # E.g., 002450000000 -> 2450000000
27
- str_hz = str_hz.sub(/^0+/, '') unless str_hz == '0'
28
- # Insert dots every 3 digits from the right
29
- str_hz.reverse.scan(/.{1,3}/).join('.').reverse
30
- end
31
- end
32
-
33
10
  # Supported Method Parameters::
34
11
  # gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
35
12
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
@@ -154,17 +131,15 @@ module PWN
154
131
  raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
155
132
 
156
133
  # Reformat positive integer frequency responses (e.g., from 'f')
157
- response_str = response_str.to_i.cast_to_pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
134
+ response_str = PWN::SDR.hz_to_s(response_str) if response_str.match?(/^\d+$/) && response_str.to_i.positive?
158
135
 
159
136
  response_str
160
137
  rescue RuntimeError => e
161
- puts 'WARNING: RF Gain is not supported by the radio backend.' if e.message.include?('Command: L RF_GAIN')
162
- puts 'WARNING: Intermediate Gain is not supported by the radio backend.' if e.message.include?('Command: L IF_GAIN')
163
- puts 'WARNING: Baseband Gain is not supported by the radio backend.' if e.message.include?('Command: L BB_GAIN')
138
+ response_str = 'Function not supported by this radio backend.' if e.message.include?('RF_GAIN') || e.message.include?('IF_GAIN') || e.message.include?('BB_GAIN')
164
139
 
165
- raise e unless e.message.include?('Command: L RF_GAIN') ||
166
- e.message.include?('Command: L IF_GAIN') ||
167
- e.message.include?('Command: L BB_GAIN')
140
+ raise e unless e.message.include?('RF_GAIN') ||
141
+ e.message.include?('IF_GAIN') ||
142
+ e.message.include?('BB_GAIN')
168
143
  rescue StandardError => e
169
144
  raise e
170
145
  end
@@ -172,36 +147,49 @@ module PWN
172
147
  # Supported Method Parameters::
173
148
  # strength_db = PWN::SDR::GQRX.measure_signal_strength(
174
149
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
175
- # strength_lock: 'optional - Strength lock in dBFS to determine signal edges (defaults to -70.0)'
150
+ # freq: 'required - Frequency to measure signal strength',
151
+ # strength_lock: 'optional - Strength lock in dBFS to determine signal edges (defaults to -70.0)',
152
+ # phase: 'optional - Phase of measurement for logging purposes (defaults to :find_candidates)'
176
153
  # )
177
154
  private_class_method def self.measure_signal_strength(opts = {})
178
155
  gqrx_sock = opts[:gqrx_sock]
156
+ freq = PWN::SDR.hz_to_i(opts[:freq])
157
+ freq = PWN::SDR.hz_to_s(freq)
158
+
179
159
  strength_lock = opts[:strength_lock] ||= -70.0
160
+ phase = opts[:phase] ||= :find_candidates
180
161
 
181
162
  attempts = 0
182
163
  strength_db = -99.9
183
164
  prev_strength_db = -99.9
165
+ distance_between_unique_samples = 0.0
166
+ samples = []
167
+ unique_samples = []
168
+ strength_measured = false
184
169
  loop do
185
170
  attempts += 1
186
171
  strength_db = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
187
172
 
188
- # Suprisingly accurate but takes longer
189
- # `break if strength_db < prev_strength_db` || attempts >= 300
190
- # is VERY accurate with
191
- # `sleep 0.0001`
192
- # but with more sampling == longer time
193
- # break if attempts >= 100 && (strength_lock > strength_db || strength_db < prev_strength_db)
194
-
195
- break if attempts >= 30 && strength_lock > strength_db
173
+ # Fast approach while still maintaining decent accuracy
174
+ samples.push(strength_db)
175
+ unique_samples = samples.uniq
176
+ if unique_samples.length > 1
177
+ prev_strength_db = unique_samples[-2]
178
+ distance_between_unique_samples = (strength_db - prev_strength_db).abs
179
+ strength_measured = true if distance_between_unique_samples.positive? && strength_lock > strength_db
180
+ end
181
+ strength_measured = true if distance_between_unique_samples.positive? && distance_between_unique_samples < 5
196
182
 
197
- break if attempts >= 300 || strength_db < prev_strength_db
183
+ break if strength_measured
198
184
 
199
- sleep 0.001
200
- prev_strength_db = strength_db
185
+ # Sleep a tiny bit to allow strength_db values to fluctuate
186
+ sleep 0.0001
201
187
  end
202
- puts "Strength Measurement Attempts: #{attempts}"
188
+ # Uncomment for debugging strength measurement attempts
189
+ # which translates to speed and accuracy refinement
190
+ puts "\tStrength Measurement Attempts: #{attempts} | Freq: #{freq} | Phase: #{phase} | Unique Samples: #{unique_samples} | dbFS Distance Unique Samples: #{distance_between_unique_samples}"
203
191
 
204
- strength_db
192
+ strength_db.round(1)
205
193
  rescue StandardError => e
206
194
  raise e
207
195
  end
@@ -213,7 +201,7 @@ module PWN
213
201
  # )
214
202
  private_class_method def self.tune_to(opts = {})
215
203
  gqrx_sock = opts[:gqrx_sock]
216
- hz = opts[:hz].to_s.cast_to_raw_hz
204
+ hz = PWN::SDR.hz_to_i(opts[:hz])
217
205
 
218
206
  current_freq = 0
219
207
  attempts = 0
@@ -230,7 +218,7 @@ module PWN
230
218
  cmd: 'f'
231
219
  )
232
220
 
233
- break if current_freq.to_s.cast_to_raw_hz == hz
221
+ break if PWN::SDR.hz_to_i(current_freq) == hz
234
222
  end
235
223
  # puts "Tuned to #{current_freq} in #{attempts} attempt(s)."
236
224
  rescue StandardError => e
@@ -253,21 +241,22 @@ module PWN
253
241
  right_candidate_signals = []
254
242
  candidate_signals = []
255
243
 
256
- # left_candidate_signals.clear
257
244
  original_hz = hz
258
245
  strength_db = 99.9
259
- puts 'Finding Beginning Edge of Signal...'
246
+ puts '*** Edge Detection: Locating Beginning of Signal...'
260
247
  while strength_db >= strength_lock
261
248
  tune_to(gqrx_sock: gqrx_sock, hz: hz)
262
249
  strength_db = measure_signal_strength(
263
250
  gqrx_sock: gqrx_sock,
264
- strength_lock: strength_lock
251
+ freq: hz,
252
+ strength_lock: strength_lock,
253
+ phase: :edge_left
265
254
  )
266
255
  candidate = {
267
- hz: hz.to_s.cast_to_raw_hz,
268
- freq: hz.to_i.cast_to_pretty_hz,
256
+ hz: PWN::SDR.hz_to_i(hz),
257
+ freq: PWN::SDR.hz_to_s(hz),
269
258
  strength: strength_db,
270
- side: :left
259
+ edge: :left
271
260
  }
272
261
  left_candidate_signals.push(candidate)
273
262
  hz -= step_hz
@@ -277,22 +266,23 @@ module PWN
277
266
 
278
267
  # Now scan forwards to find the end of the signal
279
268
  # The end of the signal is where the strength drops below strength_lock
280
- # right_candidate_signals.clear
281
269
  hz = original_hz
282
270
 
283
271
  strength_db = 99.9
284
- puts 'Finding Ending Edge of Signal...'
272
+ puts '*** Edge Detection: Locating End of Signal...'
285
273
  while strength_db >= strength_lock
286
274
  tune_to(gqrx_sock: gqrx_sock, hz: hz)
287
275
  strength_db = measure_signal_strength(
288
276
  gqrx_sock: gqrx_sock,
289
- strength_lock: strength_lock
277
+ freq: hz,
278
+ strength_lock: strength_lock,
279
+ phase: :edge_right
290
280
  )
291
281
  candidate = {
292
- hz: hz.to_s.cast_to_raw_hz,
293
- freq: hz.to_i.cast_to_pretty_hz,
282
+ hz: PWN::SDR.hz_to_i(hz),
283
+ freq: PWN::SDR.hz_to_s(hz),
294
284
  strength: strength_db,
295
- side: :right
285
+ edge: :right
296
286
  }
297
287
  right_candidate_signals.push(candidate)
298
288
  hz += step_hz
@@ -319,12 +309,13 @@ module PWN
319
309
  timestamp_start = opts[:timestamp_start]
320
310
  scan_log = opts[:scan_log]
321
311
 
322
- signals = signals_arr.sort_by { |s| s[:freq].to_s.cast_to_raw_hz }
312
+ signals = signals_arr.sort_by { |s| PWN::SDR.hz_to_i(s[:freq]) }
323
313
  # Unique signals by frequency
324
- signals.uniq! { |s| s[:freq].to_s.cast_to_raw_hz }
314
+ signals.uniq! { |s| PWN::SDR.hz_to_i(s[:freq]) }
325
315
 
326
316
  timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
327
- duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
317
+ duration_secs = Time.parse(timestamp_end).to_f - Time.parse(timestamp_start).to_f
318
+
328
319
  # Convert duration seconds to hours minutes seconds
329
320
  hours = (duration_secs / 3600).to_i
330
321
  minutes = ((duration_secs % 3600) / 60).to_i
@@ -344,55 +335,117 @@ module PWN
344
335
  JSON.pretty_generate(scan_resp)
345
336
  )
346
337
 
338
+ # Append a new line at end of file to avoid readline
339
+ # issues requiring tput reset in terminal
340
+ File.write(scan_log, "\n", mode: 'a')
341
+
347
342
  scan_resp
348
343
  rescue StandardError => e
349
344
  raise e
350
345
  end
351
346
 
352
347
  # Supported Method Parameters::
353
- # rds_resp = PWN::SDR::GQRX.decode_rds(
354
- # gqrx_sock: 'required - GQRX socket object returned from #connect method'
348
+ # best_peak = PWN::SDR::GQRX.find_best_peak(
349
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
350
+ # candidate_signals: 'required - Array of candidate signals from edge_detection',
351
+ # step_hz: 'required - Frequency step in Hz for peak finding',
352
+ # strength_lock: 'required - Strength lock in dBFS to determine signal edges'
355
353
  # )
356
-
357
- private_class_method def self.decode_rds(opts = {})
354
+ private_class_method def self.find_best_peak(opts = {})
358
355
  gqrx_sock = opts[:gqrx_sock]
356
+ candidate_signals = opts[:candidate_signals]
357
+ step_hz = opts[:step_hz]
358
+ strength_lock = opts[:strength_lock]
359
359
 
360
- # We toggle RDS off and on to reset the decoder
361
- rds_resp = gqrx_cmd(
362
- gqrx_sock: gqrx_sock,
363
- cmd: 'U RDS 0',
364
- resp_ok: 'RPRT 0'
365
- )
360
+ beg_of_signal_hz = PWN::SDR.hz_to_i(candidate_signals.first[:hz])
361
+ end_of_signal_hz = PWN::SDR.hz_to_i(candidate_signals.last[:hz])
362
+
363
+ puts "*** Analyzing Best Peak in Frequency Range: #{PWN::SDR.hz_to_s(beg_of_signal_hz)} Hz - #{PWN::SDR.hz_to_s(end_of_signal_hz)} Hz"
364
+ # puts JSON.pretty_generate(candidate_signals)
365
+
366
+ samples = []
367
+ prev_best_sample = {}
368
+ consecutive_best = 0
369
+ direction_up = true
370
+
371
+ pass_count = 0
372
+ infinite_loop_safeguard = false
373
+ while true
374
+ pass_count += 1
375
+
376
+ # Safeguard against infinite loop
377
+ # infinite_loop_safeguard = true if pass_count >= 100
378
+ infinite_loop_safeguard = true if pass_count >= 10
379
+ puts 'WARNING: Infinite loop safeguard triggered in find_best_peak!' if infinite_loop_safeguard
380
+ break if infinite_loop_safeguard
381
+
382
+ direction_up = !direction_up
383
+ start_hz_direction = direction_up ? beg_of_signal_hz : end_of_signal_hz
384
+ end_hz_direction = direction_up ? end_of_signal_hz : beg_of_signal_hz
385
+ step_hz_direction = direction_up ? step_hz : -step_hz
386
+
387
+ start_hz_direction.step(by: step_hz_direction, to: end_hz_direction) do |hz|
388
+ print '>' if direction_up
389
+ print '<' unless direction_up
390
+ tune_to(gqrx_sock: gqrx_sock, hz: hz)
391
+ strength_db = measure_signal_strength(
392
+ gqrx_sock: gqrx_sock,
393
+ freq: hz,
394
+ strength_lock: strength_lock,
395
+ phase: :find_best_peak
396
+ )
397
+ samples.push({ hz: hz, strength_db: strength_db })
398
+ end
366
399
 
367
- rds_resp = gqrx_cmd(
368
- gqrx_sock: gqrx_sock,
369
- cmd: 'U RDS 1',
370
- resp_ok: 'RPRT 0'
371
- )
400
+ # Compute fresh averaged_samples from all cumulative samples
401
+ averaged_samples = []
402
+ samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
403
+ avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size)
404
+ averaged_samples.push({ hz: hz, strength_db: avg_strength })
405
+ end
372
406
 
373
- rds_resp = {}
374
- attempts = 0
375
- max_attempts = 90
376
- skip_rds = "\n"
377
- print 'INFO: Decoding FM radio RDS data (Press ENTER to skip)...'
378
- max_attempts.times do
379
- attempts += 1
380
- rds_resp[:rds_pi] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PI')
381
- rds_resp[:rds_ps_name] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PS_NAME')
382
- rds_resp[:rds_radiotext] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_RADIOTEXT')
407
+ # Sort by hz for trimming
408
+ averaged_samples.sort_by! { |s| s[:hz] }
409
+
410
+ # Find current best for trimming threshold
411
+ best_sample = averaged_samples.max_by { |s| s[:strength_db] }
412
+ max_strength = best_sample[:strength_db].round(1)
413
+
414
+ # trim_db_threshold should bet average difference between
415
+ # samples near peak, floor to nearest 0.1 dB
416
+ trim_db_threshold = samples.map { |s| (s[:strength_db] - max_strength).abs }.sum / samples.size
417
+ trim_db_threshold = (trim_db_threshold * 10).floor / 10.0
418
+ puts "\nPass #{pass_count}: Calculated trim_db_threshold: #{trim_db_threshold} dB"
419
+ # Adaptive trim: Remove weak ends (implements the comment about trimming weakest ends)
420
+ averaged_samples.shift while !averaged_samples.empty? && averaged_samples.first[:strength_db] < max_strength - trim_db_threshold
421
+ averaged_samples.pop while !averaged_samples.empty? && averaged_samples.last[:strength_db] < max_strength - trim_db_threshold
422
+
423
+ # Update range for next pass if trimmed
424
+ unless averaged_samples.empty?
425
+ beg_of_signal_hz = averaged_samples.first[:hz]
426
+ end_of_signal_hz = averaged_samples.last[:hz]
427
+ end
383
428
 
384
- # Break if ENTER key pressed
385
- # This is useful if no RDS data is available
386
- # on the current frequency (e.g. false+)
387
- break if $stdin.ready? && $stdin.read_nonblock(1) == skip_rds
429
+ # Recalculate best_sample after trim
430
+ best_sample = averaged_samples.max_by { |s| s[:strength_db] }
388
431
 
389
- break if rds_resp[:rds_pi] != '0000' && !rds_resp[:rds_ps_name].empty? && !rds_resp[:rds_radiotext].empty?
432
+ # Check for improvement
433
+ if best_sample[:hz] == prev_best_sample[:hz]
434
+ consecutive_best += 1
435
+ else
436
+ consecutive_best = 0
437
+ end
438
+
439
+ # Dup to avoid reference issues
440
+ prev_best_sample = best_sample.dup
441
+
442
+ puts "Pass #{pass_count}: Best #{PWN::SDR.hz_to_s(best_sample[:hz])} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
390
443
 
391
- print '.'
392
- sleep 0.1
444
+ # Break if we have a stable best sample or only one sample remains
445
+ break if consecutive_best.positive? || averaged_samples.length == 1
393
446
  end
394
- puts 'complete.'
395
- rds_resp
447
+
448
+ best_sample
396
449
  rescue StandardError => e
397
450
  raise e
398
451
  end
@@ -417,7 +470,7 @@ module PWN
417
470
  # freq: 'required - Frequency to set',
418
471
  # demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
419
472
  # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
420
- # bandwidth: 'optional - Bandwidth (defaults to 200_000)',
473
+ # bandwidth: 'optional - Bandwidth (defaults to "200.000")',
421
474
  # squelch: 'optional - Squelch level to set (Defaults to current value)',
422
475
  # decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
423
476
  # record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
@@ -447,7 +500,7 @@ module PWN
447
500
 
448
501
  rds = opts[:rds] ||= false
449
502
 
450
- bandwidth = opts[:bandwidth] ||= 200_000
503
+ bandwidth = opts[:bandwidth] ||= '200.000'
451
504
  squelch = opts[:squelch]
452
505
  decoder = opts[:decoder]
453
506
  record_dir = opts[:record_dir] ||= '/tmp'
@@ -465,7 +518,7 @@ module PWN
465
518
  )
466
519
 
467
520
  mode_str = demodulator_mode.to_s.upcase
468
- passband_hz = bandwidth.to_s.cast_to_raw_hz
521
+ passband_hz = PWN::SDR.hz_to_i(bandwidth)
469
522
  gqrx_cmd(
470
523
  gqrx_sock: gqrx_sock,
471
524
  cmd: "M #{mode_str} #{passband_hz}",
@@ -475,15 +528,17 @@ module PWN
475
528
 
476
529
  tune_to(gqrx_sock: gqrx_sock, hz: freq)
477
530
  strength_db = measure_signal_strength(
478
- gqrx_sock: gqrx_sock
531
+ gqrx_sock: gqrx_sock,
532
+ freq: freq,
533
+ phase: :init_freq
479
534
  )
480
535
 
481
536
  freq_obj = {
482
- bandwidth: bandwidth,
537
+ freq: freq,
483
538
  demodulator_mode: demodulator_mode,
539
+ bandwidth: bandwidth,
484
540
  rds: rds,
485
- strength_db: strength_db,
486
- freq: freq
541
+ strength_db: strength_db
487
542
  }
488
543
 
489
544
  unless suppress_details
@@ -518,7 +573,7 @@ module PWN
518
573
  )
519
574
 
520
575
  rds_resp = nil
521
- rds_resp = decode_rds(gqrx_sock: gqrx_sock) if rds
576
+ rds_resp = PWN::SDR::Decoder.rds(gqrx_sock: gqrx_sock) if rds
522
577
 
523
578
  freq_obj[:audio_gain_db] = audio_gain_db
524
579
  freq_obj[:demod_mode_n_passband] = demod_n_passband
@@ -538,6 +593,8 @@ module PWN
538
593
  case decoder
539
594
  when :gsm
540
595
  decoder_module = PWN::SDR::Decoder::GSM
596
+ when :rds
597
+ decoder_module = PWN::SDR::Decoder::RDS
541
598
  else
542
599
  raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
543
600
  end
@@ -592,7 +649,7 @@ module PWN
592
649
  # target_freq: 'required - Target frequency of scan range',
593
650
  # demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
594
651
  # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
595
- # bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
652
+ # bandwidth: 'optional - Bandwidth in Hz (Defaults to "200.000")',
596
653
  # precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
597
654
  # strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
598
655
  # squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
@@ -611,23 +668,24 @@ module PWN
611
668
  gqrx_sock = opts[:gqrx_sock]
612
669
 
613
670
  start_freq = opts[:start_freq]
614
- hz_start = start_freq.to_s.cast_to_raw_hz
671
+ hz_start = PWN::SDR.hz_to_i(start_freq)
615
672
 
616
673
  target_freq = opts[:target_freq]
617
- hz_target = target_freq.to_s.cast_to_raw_hz
674
+ hz_target = PWN::SDR.hz_to_i(target_freq)
618
675
 
619
676
  demodulator_mode = opts[:demodulator_mode]
620
677
  rds = opts[:rds] ||= false
621
678
 
622
- bandwidth = opts[:bandwidth] ||= 200_000
679
+ bandwidth = opts[:bandwidth] ||= '200.000'
680
+
623
681
  precision = opts[:precision] ||= 1
624
682
  strength_lock = opts[:strength_lock] ||= -70.0
625
683
  squelch = opts[:squelch] ||= (strength_lock - 3.0)
626
- scan_log = opts[:scan_log] ||= "/tmp/pwn_sdr_gqrx_scan_#{hz_start.to_i.cast_to_pretty_hz}-#{hz_target.to_i.cast_to_pretty_hz}_#{log_timestamp}.json"
684
+ scan_log = opts[:scan_log] ||= "/tmp/pwn_sdr_gqrx_scan_#{PWN::SDR.hz_to_s(hz_start)}-#{PWN::SDR.hz_to_s(hz_target)}_#{log_timestamp}.json"
627
685
  location = opts[:location] ||= 'United States'
628
686
 
629
687
  step_hz = 10**(precision - 1)
630
- step = hz_start > hz_target ? -step_hz : step_hz
688
+ step_hz_direction = hz_start > hz_target ? -step_hz : step_hz
631
689
 
632
690
  # Set squelch once for the scan
633
691
  change_squelch_resp = gqrx_cmd(
@@ -649,7 +707,7 @@ module PWN
649
707
 
650
708
  # Set demodulator mode & passband once for the scan
651
709
  mode_str = demodulator_mode.to_s.upcase
652
- passband_hz = bandwidth.to_s.cast_to_raw_hz
710
+ passband_hz = PWN::SDR.hz_to_i(bandwidth)
653
711
  gqrx_cmd(
654
712
  gqrx_sock: gqrx_sock,
655
713
  cmd: "M #{mode_str} #{passband_hz}",
@@ -701,103 +759,10 @@ module PWN
701
759
 
702
760
  candidate_signals = []
703
761
 
704
- # Adaptive peak finder – trims weakest ends after each pass
705
- # Converges quickly to the true center of the bell curve
706
- find_best_peak = lambda do |opts = {}|
707
- beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.cast_to_raw_hz
708
- end_of_signal_hz = opts[:end_of_signal_hz].to_s.cast_to_raw_hz
709
-
710
- samples = []
711
- prev_best_sample = {}
712
- consecutive_best = 0
713
- direction_up = true
714
-
715
- pass_count = 0
716
- infinite_loop_safeguard = false
717
- while true
718
- pass_count += 1
719
-
720
- # Safeguard against infinite loop
721
- infinite_loop_safeguard = true if pass_count >= 100
722
- puts 'WARNING: Infinite loop safeguard triggered in find_best_peak!' if infinite_loop_safeguard
723
- break if infinite_loop_safeguard
724
-
725
- direction_up = !direction_up
726
- start_hz_direction = direction_up ? beg_of_signal_hz : end_of_signal_hz
727
- end_hz_direction = direction_up ? end_of_signal_hz : beg_of_signal_hz
728
- step_hz_direction = direction_up ? step_hz : -step_hz
729
-
730
- start_hz_direction.step(by: step_hz_direction, to: end_hz_direction) do |hz|
731
- print '>' if direction_up
732
- print '<' unless direction_up
733
- tune_to(gqrx_sock: gqrx_sock, hz: hz)
734
- strength_db = measure_signal_strength(
735
- gqrx_sock: gqrx_sock,
736
- strength_lock: strength_lock
737
- )
738
- samples.push({ hz: hz, strength_db: strength_db })
739
-
740
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.cast_to_raw_hz
741
- # puts "Sampled Frequency: #{current_hz.to_i.cast_to_pretty_hz} => Strength: #{strength_db} dBFS"
742
- end
743
-
744
- # Compute fresh averaged_samples from all cumulative samples
745
- averaged_samples = []
746
- samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
747
- avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size)
748
- # avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(2)
749
- # avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
750
- averaged_samples.push({ hz: hz, strength_db: avg_strength })
751
- end
752
-
753
- # Sort by hz for trimming
754
- averaged_samples.sort_by! { |s| s[:hz] }
755
-
756
- # Find current best for trimming threshold
757
- best_sample = averaged_samples.max_by { |s| s[:strength_db] }
758
- max_strength = best_sample[:strength_db]
759
-
760
- # trim_db_threshold should bet average difference between
761
- # samples near peak, floor to nearest 0.1 dB
762
- trim_db_threshold = samples.map { |s| (s[:strength_db] - max_strength).abs }.sum / samples.size
763
- trim_db_threshold = (trim_db_threshold * 10).floor / 10.0
764
- puts "\nPass #{pass_count}: Calculated trim_db_threshold: #{trim_db_threshold} dB"
765
- # Adaptive trim: Remove weak ends (implements the comment about trimming weakest ends)
766
- averaged_samples.shift while !averaged_samples.empty? && averaged_samples.first[:strength_db] < max_strength - trim_db_threshold
767
- averaged_samples.pop while !averaged_samples.empty? && averaged_samples.last[:strength_db] < max_strength - trim_db_threshold
768
-
769
- # Update range for next pass if trimmed
770
- unless averaged_samples.empty?
771
- beg_of_signal_hz = averaged_samples.first[:hz]
772
- end_of_signal_hz = averaged_samples.last[:hz]
773
- end
774
-
775
- # Recalculate best_sample after trim
776
- best_sample = averaged_samples.max_by { |s| s[:strength_db] }
777
-
778
- # Check for improvement
779
- if best_sample[:hz] == prev_best_sample[:hz]
780
- consecutive_best += 1
781
- else
782
- consecutive_best = 0
783
- end
784
-
785
- # Dup to avoid reference issues
786
- prev_best_sample = best_sample.dup
787
-
788
- puts "Pass #{pass_count}: Best #{best_sample[:hz].to_i.cast_to_pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
789
-
790
- # Break if no improvement in 3 consecutive passes or theres only one sample left
791
- break if consecutive_best.positive? || averaged_samples.size == 1
792
- end
793
-
794
- best_sample
795
- end
796
-
797
762
  # Begin scanning range
798
763
  puts "\n"
799
764
  puts '-' * 86
800
- puts "INFO: Scanning from #{hz_start.to_i.cast_to_pretty_hz} to #{hz_target.to_i.cast_to_pretty_hz} in steps of #{step.abs.to_i.cast_to_pretty_hz} Hz."
765
+ puts "INFO: Scanning from #{PWN::SDR.hz_to_s(hz_start)} to #{PWN::SDR.hz_to_s(hz_target)} in steps of #{PWN::SDR.hz_to_s(step_hz_direction.abs)} Hz."
801
766
  puts "If scans are slow and/or you're experiencing false positives/negatives,"
802
767
  puts 'consider adjusting the following:'
803
768
  puts "1. The SDR's sample rate in GQRX"
@@ -812,11 +777,14 @@ module PWN
812
777
 
813
778
  signals_arr = []
814
779
  hz = hz_start
815
- while hz <= hz_target
780
+
781
+ while step_hz_direction.positive? ? hz <= hz_target : hz >= hz_target
816
782
  tune_to(gqrx_sock: gqrx_sock, hz: hz)
817
783
  strength_db = measure_signal_strength(
818
784
  gqrx_sock: gqrx_sock,
819
- strength_lock: strength_lock
785
+ freq: hz,
786
+ strength_lock: strength_lock,
787
+ phase: :find_candidates
820
788
  )
821
789
 
822
790
  if strength_db >= strength_lock
@@ -829,28 +797,16 @@ module PWN
829
797
  strength_lock: strength_lock
830
798
  )
831
799
  elsif candidate_signals.length.positive?
832
- beg_of_signal_hz = candidate_signals.first[:hz]
833
- top_of_signal_hz_idx = (candidate_signals.length - 1) / 2
834
- top_of_signal_hz = candidate_signals[top_of_signal_hz_idx][:hz]
835
- end_of_signal_hz = candidate_signals.last[:hz]
836
- puts 'Candidate Signal(s) Detected:'
837
- puts JSON.pretty_generate(candidate_signals)
838
-
839
- prev_freq = prev_freq_obj[:freq].to_s.cast_to_raw_hz
840
- distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_freq).abs
841
- half_bandwidth = (bandwidth / 2).to_i
842
-
843
- puts "Key Frequencies: Begin: #{beg_of_signal_hz.to_i.cast_to_pretty_hz} Hz | Estimated Top: #{top_of_signal_hz.to_i.cast_to_pretty_hz} Hz | End: #{end_of_signal_hz.to_i.cast_to_pretty_hz} Hz"
844
-
845
- puts 'Finding Best Peak...'
846
- best_peak = find_best_peak.call(
847
- beg_of_signal_hz: beg_of_signal_hz,
848
- end_of_signal_hz: end_of_signal_hz
800
+ best_peak = find_best_peak(
801
+ gqrx_sock: gqrx_sock,
802
+ candidate_signals: candidate_signals,
803
+ step_hz: step_hz,
804
+ strength_lock: strength_lock
849
805
  )
850
806
 
851
807
  if best_peak[:hz] && best_peak[:strength_db] > strength_lock
852
808
  puts "\n**** Detected Signal ****"
853
- best_freq = best_peak[:hz].to_i.cast_to_pretty_hz
809
+ best_freq = PWN::SDR.hz_to_s(best_peak[:hz])
854
810
  best_strength_db = best_peak[:strength_db]
855
811
  prev_freq_obj = init_freq(
856
812
  gqrx_sock: gqrx_sock,
@@ -879,12 +835,12 @@ module PWN
879
835
  timestamp_start: timestamp_start,
880
836
  scan_log: scan_log
881
837
  )
882
- hz = end_of_signal_hz
838
+ hz = candidate_signals.last[:hz]
883
839
  # gets
884
840
  end
885
841
  candidate_signals.clear
886
842
  end
887
- hz += step_hz
843
+ hz += step_hz_direction
888
844
  end
889
845
 
890
846
  log_signals(
@@ -997,7 +953,7 @@ module PWN
997
953
  freq: 'required - Frequency to set',
998
954
  demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
999
955
  rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
1000
- bandwidth: 'optional - Bandwidth (defaults to 200_000)',
956
+ bandwidth: 'optional - Bandwidth (defaults to \"200.000\")',
1001
957
  decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
1002
958
  record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
1003
959
  suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
@@ -1009,7 +965,7 @@ module PWN
1009
965
  start_freq: 'required - Starting frequency',
1010
966
  target_freq: 'required - Target frequency',
1011
967
  demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
1012
- bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
968
+ bandwidth: 'optional - Bandwidth in Hz (Defaults to \"200.000\")',
1013
969
  precision: 'optional - Precision (Defaults to 1)',
1014
970
  strength_lock: 'optional - Strength lock (defaults to -70.0)',
1015
971
  squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',