pwn 0.5.532 → 0.5.534

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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'json'
4
5
  require 'time'
5
6
 
@@ -144,6 +145,49 @@ module PWN
144
145
  raise e
145
146
  end
146
147
 
148
+ # Supported Method Parameters::
149
+ # noise_floor = PWN::SDR::GQRX.measure_noise_floor(
150
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
151
+ # freq: 'required - Frequency to measure noise floor',
152
+ # precision: 'required - Frequency step precision',
153
+ # step_hz_direction: 'required - Frequency step in Hz direction for noise floor measurement'
154
+ # )
155
+ private_class_method def self.measure_noise_floor(opts = {})
156
+ gqrx_sock = opts[:gqrx_sock]
157
+ freq = opts[:freq]
158
+ precision = opts[:precision]
159
+ step_hz_direction = opts[:step_hz_direction]
160
+ freqs_to_sample = 10
161
+ samples_per_freq = 100
162
+
163
+ # Quickly sample multiple frequencies around target frequency
164
+ start_hz = PWN::SDR.hz_to_i(freq)
165
+ hz = start_hz
166
+ # puts step_hz_direction.class; gets
167
+ target_hz = start_hz + (step_hz_direction * freqs_to_sample)
168
+ noise_floors = []
169
+ puts "*** Sampling #{freqs_to_sample} Noise Floor to Dynamically Determine Squelch Level"
170
+ while (step_hz_direction.positive? && hz <= target_hz) || (step_hz_direction.negative? && hz >= target_hz)
171
+ tune_to(gqrx_sock: gqrx_sock, hz: hz)
172
+ print '.'
173
+
174
+ strengths = []
175
+ samples_per_freq.times do
176
+ strength_db = cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
177
+ strengths << strength_db
178
+ sleep 0.001
179
+ end
180
+ freq_noise_floor = strengths.sum / strengths.size
181
+ noise_floors.push(freq_noise_floor)
182
+ hz += step_hz_direction
183
+ end
184
+
185
+ noise_floor = noise_floors.min
186
+ noise_floor.round(1)
187
+ rescue StandardError => e
188
+ raise e
189
+ end
190
+
147
191
  # Supported Method Parameters::
148
192
  # strength_db = PWN::SDR::GQRX.measure_signal_strength(
149
193
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
@@ -318,36 +362,61 @@ module PWN
318
362
  raise e
319
363
  end
320
364
 
365
+ # Supported Method Parameters::
366
+ # duration = PWN::SDR::GQRX.duration_between(
367
+ # timestamp_start: 'required - Start timestamp',
368
+ # timestamp_end: 'required - End timestamp'
369
+ # )
370
+ private_class_method def self.duration_between(opts = {})
371
+ timestamp_start = opts[:timestamp_start]
372
+ timestamp_end = opts[:timestamp_end]
373
+ raise 'ERROR: timestamp_start && timestamp_end must are required parameters.' if timestamp_start.nil? || timestamp_end.nil?
374
+
375
+ duration_secs = Time.parse(timestamp_end).to_f - Time.parse(timestamp_start).to_f
376
+
377
+ # Convert duration seconds to hours minutes seconds
378
+ hours = (duration_secs / 3600).to_i
379
+ minutes = ((duration_secs % 3600) / 60).to_i
380
+ seconds = (duration_secs % 60).to_i
381
+ format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
382
+ rescue StandardError => e
383
+ raise e
384
+ end
385
+
321
386
  # Supported Method Parameters::
322
387
  # scan_resp = PWN::SDR::GQRX.log_signals(
323
- # signals_arr: 'required - Array of detected signals',
388
+ # signals_detected: 'required - Array of detected signals',
324
389
  # timestamp_start: 'required - Scan start timestamp',
325
- # scan_log: 'required - Path to save detected signals log'
390
+ # scan_log: 'required - Path to save detected signals log',
391
+ # iteration_metrics: 'optional - Hash of iteration metrics per range iteration'
326
392
  # )
327
393
  private_class_method def self.log_signals(opts = {})
328
- signals_arr = opts[:signals_arr]
394
+ signals_detected = opts[:signals_detected]
329
395
  timestamp_start = opts[:timestamp_start]
330
396
  scan_log = opts[:scan_log]
331
397
 
332
- signals = signals_arr.sort_by { |s| PWN::SDR.hz_to_i(s[:freq]) }
398
+ existing_scan_resp = JSON.parse(File.read(scan_log), symbolize_names: true) if File.exist?(scan_log)
399
+
400
+ # BUG: iteration_metrics not being properly initialized from existing log
401
+ iteration_metrics = opts[:iteration_metrics]
402
+ 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
+ iteration_metrics ||= []
404
+
405
+ signals = signals_detected.sort_by { |s| PWN::SDR.hz_to_i(s[:freq]) }
333
406
  # Unique signals by frequency
334
407
  signals.uniq! { |s| PWN::SDR.hz_to_i(s[:freq]) }
335
408
 
336
409
  timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
337
- duration_secs = Time.parse(timestamp_end).to_f - Time.parse(timestamp_start).to_f
338
-
339
- # Convert duration seconds to hours minutes seconds
340
- hours = (duration_secs / 3600).to_i
341
- minutes = ((duration_secs % 3600) / 60).to_i
342
- seconds = (duration_secs % 60).to_i
343
- duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
410
+ duration = duration_between(timestamp_start: timestamp_start, timestamp_end: timestamp_end)
344
411
 
412
+ # TODO: Implement iteration_metrics logging per range iteration
345
413
  scan_resp = {
346
414
  signals: signals,
347
415
  total: signals.length,
348
416
  timestamp_start: timestamp_start,
349
417
  timestamp_end: timestamp_end,
350
- duration: duration
418
+ duration: duration,
419
+ iteration_metrics: iteration_metrics
351
420
  }
352
421
 
353
422
  File.write(
@@ -391,14 +460,16 @@ module PWN
391
460
 
392
461
  pass_count = 0
393
462
  infinite_loop_safeguard = false
463
+ # loop do
464
+ # Why doesn't this work with loop do ???
394
465
  while true
395
466
  pass_count += 1
396
467
 
397
468
  # Safeguard against infinite loop
398
469
  # infinite_loop_safeguard = true if pass_count >= 100
399
- infinite_loop_safeguard = true if pass_count >= 10
400
- puts 'WARNING: Infinite loop safeguard triggered in find_best_peak!' if infinite_loop_safeguard
401
- break if infinite_loop_safeguard
470
+ # infinite_loop_safeguard = true if pass_count >= 10
471
+ # puts 'WARNING: Infinite loop safeguard triggered in find_best_peak!' if infinite_loop_safeguard
472
+ # break if infinite_loop_safeguard
402
473
 
403
474
  direction_up = !direction_up
404
475
  start_hz_direction = direction_up ? beg_of_signal_hz : end_of_signal_hz
@@ -450,6 +521,7 @@ module PWN
450
521
 
451
522
  # Recalculate best_sample after trim
452
523
  best_sample = averaged_samples.max_by { |s| s[:strength_db] }
524
+ next unless best_sample.is_a?(Hash)
453
525
 
454
526
  # Check for improvement
455
527
  if best_sample[:hz] == prev_best_sample[:hz]
@@ -494,7 +566,8 @@ module PWN
494
566
  # bandwidth: 'optional - Bandwidth (defaults to "200.000")',
495
567
  # squelch: 'optional - Squelch level to set (Defaults to current value)',
496
568
  # decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
497
- # record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
569
+ # udp_ip: 'optional - UDP IP address for decoder module (defaults to 127.0.0.1)',
570
+ # udp_port: 'optional - UDP port for decoder module (defaults to 7355)',
498
571
  # suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
499
572
  # keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
500
573
  # )
@@ -523,12 +596,11 @@ module PWN
523
596
  bandwidth = opts[:bandwidth] ||= '200.000'
524
597
  squelch = opts[:squelch]
525
598
  decoder = opts[:decoder]
526
- record_dir = opts[:record_dir] ||= '/tmp'
599
+ udp_ip = opts[:udp_ip]
600
+ udp_port = opts[:udp_port]
527
601
  suppress_details = opts[:suppress_details] || false
528
602
  keep_alive = opts[:keep_alive] || false
529
603
 
530
- raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
531
-
532
604
  unless keep_alive
533
605
  squelch = cmd(gqrx_sock: gqrx_sock, cmd: 'l SQL').to_f if squelch.nil?
534
606
  change_squelch_resp = cmd(
@@ -559,7 +631,8 @@ module PWN
559
631
  demodulator_mode: demodulator_mode,
560
632
  bandwidth: bandwidth,
561
633
  strength_db: strength_db,
562
- decoder: decoder
634
+ decoder: decoder,
635
+ squelch: squelch
563
636
  }
564
637
 
565
638
  unless suppress_details
@@ -604,11 +677,11 @@ module PWN
604
677
  if decoder
605
678
  decoder = decoder.to_s.to_sym
606
679
  decoder_module = nil
607
- decoder_thread = nil
608
- record_path = nil
609
680
 
610
681
  # Resolve decoder module via case statement for extensibility
611
682
  case decoder
683
+ when :flex
684
+ decoder_module = PWN::SDR::Decoder::Flex
612
685
  when :gsm
613
686
  decoder_module = PWN::SDR::Decoder::GSM
614
687
  when :pocsag
@@ -619,21 +692,12 @@ module PWN
619
692
  raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
620
693
  end
621
694
 
622
- # Ensure recording is off before starting
623
- unless decoder == :rds
624
- record_path = "#{record_dir}/gqrx_recording.wav"
625
- freq_obj[:record_path] = record_path
626
- end
627
-
628
695
  # Initialize and start decoder (module style: .start returns thread)
629
696
  freq_obj[:gqrx_sock] = gqrx_sock
697
+ freq_obj[:udp_ip] = udp_ip
698
+ freq_obj[:udp_port] = udp_port
630
699
  freq_obj[:decoder_module] = decoder_module
631
- freq_obj[:record_path] = record_path
632
- decoder_thread = decoder_module.decode(
633
- freq_obj: freq_obj,
634
- record_dir: record_dir
635
- )
636
- # freq_obj[:decoder_thread] = decoder_thread if decoder_thread.is_a?(Thread)
700
+ decoder_module.decode(freq_obj: freq_obj)
637
701
  end
638
702
  end
639
703
 
@@ -653,7 +717,7 @@ module PWN
653
717
  # precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
654
718
  # strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
655
719
  # squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
656
- # audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
720
+ # audio_gain_db: 'optional - Audio gain in dB (defaults to 0.0)',
657
721
  # rf_gain: 'optional - RF gain (defaults to 0.0)',
658
722
  # intermediate_gain: 'optional - Intermediate gain (defaults to 32.0)',
659
723
  # baseband_gain: 'optional - Baseband gain (defaults to 10.0)',
@@ -664,157 +728,177 @@ module PWN
664
728
 
665
729
  public_class_method def self.scan_range(opts = {})
666
730
  timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
731
+ range_timestamp_start = ''
667
732
 
668
- loop_count = 1
669
- loop do
670
- gqrx_sock = opts[:gqrx_sock]
733
+ gqrx_sock = opts[:gqrx_sock]
671
734
 
672
- ranges = opts[:ranges]
673
- raise 'ERROR: ranges must be an Array of Hash objects with :start_freq and :target_freq keys.' unless ranges.is_a?(Array) && ranges.all? { |r| r.is_a?(Hash) && r.key?(:start_freq) && r.key?(:target_freq) }
735
+ ranges = opts[:ranges]
736
+ raise 'ERROR: ranges must be an Array of Hash objects with :start_freq and :target_freq keys.' unless ranges.is_a?(Array) && ranges.all? { |r| r.is_a?(Hash) && r.key?(:start_freq) && r.key?(:target_freq) }
674
737
 
675
- # Verify all frequencies are valid
676
- ranges.each do |r|
677
- start_freq = r[:start_freq]
678
- hz_start = PWN::SDR.hz_to_i(start_freq)
679
- raise "ERROR: Invalid start_freq '#{start_freq}' provided." if hz_start.zero?
738
+ demodulator_mode = opts[:demodulator_mode]
680
739
 
681
- target_freq = r[:target_freq]
682
- hz_target = PWN::SDR.hz_to_i(target_freq)
683
- raise "ERROR: Invalid target_freq '#{target_freq}' provided." if hz_target.zero?
684
- end
740
+ bandwidth = opts[:bandwidth] ||= '200.000'
685
741
 
686
- first_start_freq = ranges.first[:start_freq]
687
- hz_start = PWN::SDR.hz_to_i(first_start_freq)
742
+ precision = opts[:precision] ||= 1
743
+ raise 'ERROR: precision must be an Integer between 1 and 12.' unless precision.is_a?(Integer) && precision.between?(1, 12)
688
744
 
689
- first_target_freq = ranges.first[:target_freq]
690
- hz_target = PWN::SDR.hz_to_i(first_target_freq)
745
+ step_hz = 10**(precision - 1)
691
746
 
692
- demodulator_mode = opts[:demodulator_mode]
747
+ strength_lock = opts[:strength_lock] ||= -70.0
748
+ squelch = opts[:squelch] ||= (strength_lock - 3.0)
749
+ raise 'ERROR: squelch must always be less than strength_lock.' if squelch >= strength_lock
693
750
 
694
- bandwidth = opts[:bandwidth] ||= '200.000'
751
+ decoder = opts[:decoder]
752
+ keep_looping = opts[:keep_looping] || false
753
+ log_timestamp = Time.now.strftime('%Y-%m-%d')
695
754
 
696
- precision = opts[:precision] ||= 1
697
- strength_lock = opts[:strength_lock] ||= -70.0
698
- squelch = opts[:squelch] ||= (strength_lock - 3.0)
699
- decoder = opts[:decoder]
755
+ location = opts[:location] ||= 'United States'
700
756
 
701
- keep_looping = opts[:keep_looping] || false
757
+ # This is for looping through ranges indefinitely if keep_looping is true
758
+ # Generate ranges strings for log filename
759
+ range_str = ''
760
+ ranges.each do |range|
761
+ start_freq = range[:start_freq]
762
+ hz_start = PWN::SDR.hz_to_i(start_freq)
763
+ hz_start_str = PWN::SDR.hz_to_s(hz_start)
702
764
 
703
- log_timestamp = Time.now.strftime('%Y-%m-%d')
704
- 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"
765
+ target_freq = range[:target_freq]
766
+ hz_target = PWN::SDR.hz_to_i(target_freq)
767
+ hz_target_str = PWN::SDR.hz_to_s(hz_target)
705
768
 
706
- if keep_looping
707
- # inject _lN before file extension if keep_looping is true
708
- scan_log.gsub!("_l#{loop_count}", '')
709
- scan_log = File.join(
710
- File.dirname(scan_log),
711
- "#{File.basename(scan_log, '.*')}_l#{loop_count}#{File.extname(scan_log)}"
712
- )
713
- end
769
+ range_str = "#{range_str}_#{hz_start_str}-#{hz_target_str}"
770
+ end
771
+ scan_log = opts[:scan_log] ||= "/tmp/pwn_sdr_gqrx_scan#{range_str}_#{log_timestamp}.json"
714
772
 
715
- location = opts[:location] ||= 'United States'
773
+ iteration_metrics = []
774
+ candidate_signals = []
775
+ signals_detected = []
776
+ iteration_total = 1
777
+ signals_detected_total = 0
778
+ loop do
779
+ signals_detected_delta = 0
780
+ iter_metrics_hash = {}
781
+ ranges.each do |range|
782
+ range_timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
783
+ iter_metrics_hash[:iteration] = iteration_total
784
+ iter_metrics_hash[:range] = range
785
+ iter_metrics_hash[:timestamp_start] = range_timestamp_start
716
786
 
717
- step_hz = 10**(precision - 1)
718
- step_hz_direction = hz_start > hz_target ? -step_hz : step_hz
787
+ # Verify all frequencies are valid
788
+ start_freq = range[:start_freq]
789
+ hz_start = PWN::SDR.hz_to_i(start_freq)
790
+ raise "ERROR: Invalid start_freq '#{start_freq}' provided." if hz_start.zero?
719
791
 
720
- # Begin scanning range
721
- puts "\n"
722
- puts '-' * 86
723
- puts 'SESSION PARAMS >> Scan Range(s):'
724
- puts ranges
725
- puts "SESSION PARAMS >> Step Increment: #{PWN::SDR.hz_to_s(step_hz_direction.abs)} Hz."
726
- puts "SESSION PARAMS >> Continuously Loop through Scan Range(s): #{keep_looping}"
727
- puts "\nIf scans are slow and/or you're experiencing false positives/negatives,"
728
- puts 'consider adjusting the following:'
729
- puts "1. The SDR's sample rate in GQRX"
730
- puts "\s\s- Click on `Configure I/O devices`."
731
- puts "\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000)."
732
- puts '2. Adjust the :strength_lock parameter.'
733
- puts '3. Adjust the :precision parameter.'
734
- puts '4. Disable AI introspection in PWN::Env'
735
- puts 'Happy scanning!'
736
- puts '-' * 86
737
- # print 'Pressing ENTER to begin scan...'
738
- # gets
739
- puts "\n\n\n"
740
-
741
- # Set squelch once for the scan
742
- change_squelch_resp = cmd(
743
- gqrx_sock: gqrx_sock,
744
- cmd: "L SQL #{squelch}",
745
- resp_ok: 'RPRT 0'
746
- )
792
+ target_freq = range[:target_freq]
793
+ hz_target = PWN::SDR.hz_to_i(target_freq)
794
+ hz_target_str = PWN::SDR.hz_to_s(hz_target)
795
+ raise "ERROR: Invalid target_freq '#{target_freq}' provided." if hz_target.zero?
747
796
 
748
- # We always disable RDS decoding at during the scan
749
- # to prevent unnecessary processing overhead.
750
- # We return the rds boolean in the scan_resp object
751
- # so it will be picked up and used appropriately
752
- # when calling analyze_scan or analyze_log methods.
753
- rds_resp = cmd(
754
- gqrx_sock: gqrx_sock,
755
- cmd: 'U RDS 0',
756
- resp_ok: 'RPRT 0'
757
- )
797
+ step_hz_direction = hz_start > hz_target ? -step_hz : step_hz
798
+ noise_floor = measure_noise_floor(
799
+ gqrx_sock: gqrx_sock,
800
+ freq: start_freq,
801
+ precision: precision,
802
+ step_hz_direction: step_hz_direction
803
+ )
804
+ if squelch < noise_floor
805
+ squelch = noise_floor.round + 7
806
+ strength_lock = squelch + 3.0
807
+ puts "Adjusted strength_lock to #{strength_lock} dBFS and squelch to #{squelch} dBFS based on measured noise floor. This ensures proper signal detection..."
808
+ end
758
809
 
759
- # Set demodulator mode & passband once for the scan
760
- mode_str = demodulator_mode.to_s.upcase
761
- passband_hz = PWN::SDR.hz_to_i(bandwidth)
762
- cmd(
763
- gqrx_sock: gqrx_sock,
764
- cmd: "M #{mode_str} #{passband_hz}",
765
- resp_ok: 'RPRT 0'
766
- )
810
+ # Begin scanning range
811
+ puts "\n"
812
+ puts '-' * 86
813
+ puts 'SESSION PARAMS >> Scan Range(s):'
814
+ puts ranges
815
+ puts "SESSION PARAMS >> Step Increment: #{PWN::SDR.hz_to_s(step_hz_direction.abs)} Hz."
816
+ puts "SESSION PARAMS >> Continuously Loop through Scan Range(s): #{keep_looping}"
817
+ puts "\nIf scans are slow and/or you're experiencing false positives/negatives,"
818
+ puts 'consider adjusting the following:'
819
+ puts "1. The SDR's sample rate in GQRX"
820
+ puts "\s\s- Click on `Configure I/O devices`."
821
+ puts "\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000)."
822
+ puts '2. Adjust the :strength_lock parameter.'
823
+ puts '3. Adjust the :precision parameter.'
824
+ puts '4. Disable AI introspection in PWN::Env'
825
+ puts 'Happy scanning!'
826
+ puts '-' * 86
827
+ # print 'Pressing ENTER to begin scan...'
828
+ # gets
829
+ puts "\n\n\n"
767
830
 
768
- audio_gain_db = opts[:audio_gain_db] ||= 6.0
769
- audio_gain_db = audio_gain_db.to_f
770
- audio_gain_db_resp = cmd(
771
- gqrx_sock: gqrx_sock,
772
- cmd: "L AF #{audio_gain_db}",
773
- resp_ok: 'RPRT 0'
774
- )
831
+ # Set squelch once for each range
832
+ change_squelch_resp = cmd(
833
+ gqrx_sock: gqrx_sock,
834
+ cmd: "L SQL #{squelch}",
835
+ resp_ok: 'RPRT 0'
836
+ )
775
837
 
776
- rf_gain = opts[:rf_gain] ||= 0.0
777
- rf_gain = rf_gain.to_f
778
- rf_gain_resp = cmd(
779
- gqrx_sock: gqrx_sock,
780
- cmd: "L RF_GAIN #{rf_gain}",
781
- resp_ok: 'RPRT 0'
782
- )
838
+ # We always disable RDS decoding during the scan
839
+ # to prevent unnecessary processing overhead.
840
+ # We return the rds boolean in the scan_resp object
841
+ # so it will be picked up and used appropriately
842
+ # when calling analyze_scan or analyze_log methods.
843
+ rds_resp = cmd(
844
+ gqrx_sock: gqrx_sock,
845
+ cmd: 'U RDS 0',
846
+ resp_ok: 'RPRT 0'
847
+ )
783
848
 
784
- intermediate_gain = opts[:intermediate_gain] ||= 32.0
785
- intermediate_gain = intermediate_gain.to_f
786
- intermediate_resp = cmd(
787
- gqrx_sock: gqrx_sock,
788
- cmd: "L IF_GAIN #{intermediate_gain}",
789
- resp_ok: 'RPRT 0'
790
- )
849
+ # Set demodulator mode & passband once for the scan
850
+ mode_str = demodulator_mode.to_s.upcase
851
+ passband_hz = PWN::SDR.hz_to_i(bandwidth)
852
+ cmd(
853
+ gqrx_sock: gqrx_sock,
854
+ cmd: "M #{mode_str} #{passband_hz}",
855
+ resp_ok: 'RPRT 0'
856
+ )
791
857
 
792
- baseband_gain = opts[:baseband_gain] ||= 10.0
793
- baseband_gain = baseband_gain.to_f
794
- baseband_resp = cmd(
795
- gqrx_sock: gqrx_sock,
796
- cmd: "L BB_GAIN #{baseband_gain}",
797
- resp_ok: 'RPRT 0'
798
- )
858
+ audio_gain_db = opts[:audio_gain_db] ||= 0.0
859
+ audio_gain_db = audio_gain_db.to_f
860
+ audio_gain_db_resp = cmd(
861
+ gqrx_sock: gqrx_sock,
862
+ cmd: "L AF #{audio_gain_db}",
863
+ resp_ok: 'RPRT 0'
864
+ )
799
865
 
800
- prev_freq_obj = init_freq(
801
- gqrx_sock: gqrx_sock,
802
- freq: hz_start,
803
- precision: precision,
804
- demodulator_mode: demodulator_mode,
805
- bandwidth: bandwidth,
806
- squelch: squelch,
807
- decoder: decoder,
808
- suppress_details: true,
809
- keep_alive: true
810
- )
866
+ rf_gain = opts[:rf_gain] ||= 0.0
867
+ rf_gain = rf_gain.to_f
868
+ rf_gain_resp = cmd(
869
+ gqrx_sock: gqrx_sock,
870
+ cmd: "L RF_GAIN #{rf_gain}",
871
+ resp_ok: 'RPRT 0'
872
+ )
873
+
874
+ intermediate_gain = opts[:intermediate_gain] ||= 32.0
875
+ intermediate_gain = intermediate_gain.to_f
876
+ intermediate_resp = cmd(
877
+ gqrx_sock: gqrx_sock,
878
+ cmd: "L IF_GAIN #{intermediate_gain}",
879
+ resp_ok: 'RPRT 0'
880
+ )
811
881
 
812
- candidate_signals = []
882
+ baseband_gain = opts[:baseband_gain] ||= 10.0
883
+ baseband_gain = baseband_gain.to_f
884
+ baseband_resp = cmd(
885
+ gqrx_sock: gqrx_sock,
886
+ cmd: "L BB_GAIN #{baseband_gain}",
887
+ resp_ok: 'RPRT 0'
888
+ )
813
889
 
814
- signals_arr = []
815
- hz = hz_start
890
+ prev_freq_obj = init_freq(
891
+ gqrx_sock: gqrx_sock,
892
+ freq: hz_start,
893
+ precision: precision,
894
+ demodulator_mode: demodulator_mode,
895
+ bandwidth: bandwidth,
896
+ squelch: squelch,
897
+ decoder: decoder,
898
+ suppress_details: true,
899
+ keep_alive: true
900
+ )
816
901
 
817
- ranges.each do |range|
818
902
  start_freq = range[:start_freq]
819
903
  hz_start = PWN::SDR.hz_to_i(start_freq)
820
904
  hz = hz_start
@@ -824,7 +908,8 @@ module PWN
824
908
 
825
909
  # puts "#{range} #{start_freq} (#{hz_start})to #{target_freq} (#{hz_target})"
826
910
  # gets
827
- while step_hz_direction.positive? ? hz <= hz_target : hz >= hz_target
911
+ # while step_hz_direction.positive? ? hz <= hz_target : hz >= hz_target
912
+ while (step_hz_direction.positive? && hz <= hz_target) || (step_hz_direction.negative? && hz >= hz_target)
828
913
  tune_to(gqrx_sock: gqrx_sock, hz: hz)
829
914
  strength_db = measure_signal_strength(
830
915
  gqrx_sock: gqrx_sock,
@@ -870,6 +955,7 @@ module PWN
870
955
  )
871
956
  prev_freq_obj[:strength_lock] = strength_lock
872
957
  prev_freq_obj[:strength_db] = best_strength_db.round(1)
958
+ prev_freq_obj[:iteration] = iteration_total
873
959
 
874
960
  system_role_content = "Analyze signal data captured by a software-defined-radio using GQRX at the following location: #{location}. Respond with just FCC information about the transmission if available. If the frequency is unlicensed or not found in FCC records, state that clearly. Be clear and concise in your analysis."
875
961
  ai_analysis = PWN::AI::Introspection.reflect_on(
@@ -882,9 +968,9 @@ module PWN
882
968
  puts JSON.pretty_generate(prev_freq_obj)
883
969
  puts '-' * 86
884
970
  puts "\n\n\n"
885
- signals_arr.push(prev_freq_obj)
971
+ signals_detected.push(prev_freq_obj)
886
972
  log_signals(
887
- signals_arr: signals_arr,
973
+ signals_detected: signals_detected,
888
974
  timestamp_start: timestamp_start,
889
975
  scan_log: scan_log
890
976
  )
@@ -897,20 +983,53 @@ module PWN
897
983
  end
898
984
 
899
985
  log_signals(
900
- signals_arr: signals_arr,
986
+ signals_detected: signals_detected,
901
987
  timestamp_start: timestamp_start,
902
988
  scan_log: scan_log
903
989
  )
904
990
  end
905
991
  break unless keep_looping
906
992
 
907
- print "\nScan iteration ##{loop_count} complete. Resuming in 30 seconds. Press CTRL+C to exit"
908
- 30.times do
993
+ # Determine how many new signals were detected this iteration
994
+ # Reduces signals_detected to an array of unique frequencies only
995
+ signals_detected.uniq! { |s| PWN::SDR.hz_to_i(s[:freq]) }
996
+ signals_detected_total = signals_detected.select { |s| s[:iteration] == iteration_total }.length
997
+ signals_detected_delta = signals_detected_total - signals_detected_delta
998
+ start_next_iteration = case signals_detected_delta
999
+ when 0
1000
+ 30
1001
+ when 1..5
1002
+ 10
1003
+ else
1004
+ 5
1005
+ end
1006
+
1007
+ range_timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
1008
+ iter_metrics_hash[:timestamp_end] = range_timestamp_end
1009
+
1010
+ duration = duration_between(timestamp_start: range_timestamp_start, timestamp_end: range_timestamp_end)
1011
+ iter_metrics_hash[:duration] = duration
1012
+ iter_metrics_hash[:signals_detected] = signals_detected_delta
1013
+
1014
+ iteration_metrics.push(iter_metrics_hash)
1015
+ puts "\nScan iteration(s) ##{iteration_total} complete."
1016
+ puts JSON.pretty_generate(iter_metrics_hash)
1017
+
1018
+ puts "Resuming next scan iteration in #{start_next_iteration} seconds. Press CTRL+C to exit"
1019
+ start_next_iteration.times do
909
1020
  print '.'
910
1021
  sleep 1
911
1022
  end
912
1023
  puts "\n"
913
- loop_count += 1
1024
+
1025
+ # Log current signals one last time just to capture scan iterations accurately
1026
+ iteration_total += 1
1027
+ log_signals(
1028
+ signals_detected: signals_detected,
1029
+ timestamp_start: timestamp_start,
1030
+ scan_log: scan_log,
1031
+ iteration_metrics: iteration_metrics
1032
+ )
914
1033
  end
915
1034
  rescue Interrupt
916
1035
  puts "\nCTRL+C detected - goodbye."
@@ -940,8 +1059,38 @@ module PWN
940
1059
  scan_resp[:signals].each do |signal|
941
1060
  # puts JSON.pretty_generate(signal)
942
1061
  signal[:gqrx_sock] = gqrx_sock
1062
+
943
1063
  # This is required to keep connection alive during analysis
944
1064
  signal[:keep_alive] = true
1065
+
1066
+ # We do this because we need keep_alive true for init_freq calls below
1067
+ squelch = signal[:squelch]
1068
+ squelch = cmd(gqrx_sock: gqrx_sock, cmd: 'l SQL').to_f if squelch.nil?
1069
+ change_squelch_resp = cmd(
1070
+ gqrx_sock: gqrx_sock,
1071
+ cmd: "L SQL #{squelch}",
1072
+ resp_ok: 'RPRT 0'
1073
+ )
1074
+
1075
+ audio_gain_db = signal[:audio_gain_db] ||= 0.0
1076
+ audio_gain_db = audio_gain_db.to_f
1077
+ audio_gain_db_resp = cmd(
1078
+ gqrx_sock: gqrx_sock,
1079
+ cmd: "L AF #{audio_gain_db}",
1080
+ resp_ok: 'RPRT 0'
1081
+ )
1082
+
1083
+ demodulator_mode = signal[:demodulator_mode] || :WFM
1084
+ mode_str = demodulator_mode.to_s.upcase
1085
+
1086
+ bandwidth = signal[:bandwidth] ||= '200.000'
1087
+ passband_hz = PWN::SDR.hz_to_i(bandwidth)
1088
+ cmd(
1089
+ gqrx_sock: gqrx_sock,
1090
+ cmd: "M #{mode_str} #{passband_hz}",
1091
+ resp_ok: 'RPRT 0'
1092
+ )
1093
+
945
1094
  freq_obj = init_freq(signal)
946
1095
  freq_obj = signal.merge(freq_obj)
947
1096
  # Redact gqrx_sock from output
@@ -986,6 +1135,96 @@ module PWN
986
1135
  raise e
987
1136
  end
988
1137
 
1138
+ # Supported Method Parameters::
1139
+ # udp_listener = PWN::SDR::GQRX.listen_udp(
1140
+ # udp_ip: 'optional - IP address to bind UDP listener (defaults to 127.0.0.1)',
1141
+ # upd_port: 'optional - Port to bind UDP listener (defaults to 7355)'
1142
+ # )
1143
+
1144
+ public_class_method def self.listen_udp(opts = {})
1145
+ udp_ip = opts[:udp_ip] ||= '127.0.0.1'
1146
+ udp_port = opts[:udp_port] ||= 7355
1147
+
1148
+ PWN::Plugins::Sock.listen(
1149
+ server_ip: udp_ip,
1150
+ port: udp_port,
1151
+ protocol: :udp,
1152
+ detach: true
1153
+ )
1154
+ rescue StandardError => e
1155
+ raise e
1156
+ end
1157
+
1158
+ # Supported Method Parameters::
1159
+ # PWN::SDR::GQRX.disconnect_udp(
1160
+ # udp_listener: 'required - UDP socket object returned from #listen_udp method'
1161
+ # )
1162
+
1163
+ public_class_method def self.disconnect_udp(opts = {})
1164
+ udp_listener = opts[:udp_listener]
1165
+ raise 'ERROR: udp_sock is required!' if udp_listener.nil?
1166
+
1167
+ PWN::Plugins::Sock.disconnect(sock_obj: udp_listener) unless udp_listener.closed?
1168
+ rescue StandardError => e
1169
+ raise e
1170
+ end
1171
+
1172
+ # Supported Method Parameters::
1173
+ # iq_raw_file = PWN::SDR::GQRX.record(
1174
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method'
1175
+ # )
1176
+
1177
+ public_class_method def self.record(opts = {})
1178
+ gqrx_sock = opts[:gqrx_sock]
1179
+ raise 'ERROR: gqrx_sock is required!' if gqrx_sock.nil?
1180
+
1181
+ # Toggle I/Q RECORD on in GQRX for brevity
1182
+ cmd(
1183
+ gqrx_sock: gqrx_sock,
1184
+ cmd: 'U IQRECORD 0',
1185
+ resp_ok: 'RPRT 0'
1186
+ )
1187
+
1188
+ cmd(
1189
+ gqrx_sock: gqrx_sock,
1190
+ cmd: 'U IQRECORD 1',
1191
+ resp_ok: 'RPRT 0'
1192
+ )
1193
+
1194
+ record_dir = Dir.home
1195
+ iq_raw_file = Dir.glob("#{record_dir}/gqrx_*.raw").max_by { |f| File.mtime(f) }
1196
+ raise 'ERROR: No GQRX .raw I/Q data file found!' unless iq_raw_file
1197
+
1198
+ iq_raw_file
1199
+ rescue StandardError => e
1200
+ raise e
1201
+ end
1202
+
1203
+ # Supported Method Parameters::
1204
+ # PWN::SDR::GQRX.stop_recording(
1205
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
1206
+ # iq_raw_file: 'required - iq_raw_file returned from #connect method'
1207
+ # )
1208
+
1209
+ public_class_method def self.stop_recording(opts = {})
1210
+ gqrx_sock = opts[:gqrx_sock]
1211
+ raise 'ERROR: gqrx_sock is required!' if gqrx_sock.nil?
1212
+
1213
+ iq_raw_file = opts[:iq_raw_file]
1214
+ raise 'ERROR: iq_raw_file is required!' if iq_raw_file.nil?
1215
+
1216
+ # Toggle IQRECORD off
1217
+ cmd(
1218
+ gqrx_sock: gqrx_sock,
1219
+ cmd: 'U IQRECORD 0',
1220
+ resp_ok: 'RPRT 0'
1221
+ )
1222
+
1223
+ FileUtils.rm_f(iq_raw_file)
1224
+ rescue StandardError => e
1225
+ raise e
1226
+ end
1227
+
989
1228
  # Supported Method Parameters::
990
1229
  # PWN::SDR::GQRX.disconnect(
991
1230
  # gqrx_sock: 'required - GQRX socket object returned from #connect method'
@@ -1028,7 +1267,6 @@ module PWN
1028
1267
  demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
1029
1268
  bandwidth: 'optional - Bandwidth (defaults to \"200.000\")',
1030
1269
  decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
1031
- record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
1032
1270
  suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
1033
1271
  keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
1034
1272
  )
@@ -1041,7 +1279,7 @@ module PWN
1041
1279
  precision: 'optional - Precision (Defaults to 1)',
1042
1280
  strength_lock: 'optional - Strength lock (defaults to -70.0)',
1043
1281
  squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
1044
- audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
1282
+ audio_gain_db: 'optional - Audio gain in dB (defaults to 0.0)',
1045
1283
  rf_gain: 'optional - RF gain (defaults to 0.0)',
1046
1284
  intermediate_gain: 'optional - Intermediate gain (defaults to 32.0)',
1047
1285
  baseband_gain: 'optional - Baseband gain (defaults to 10.0)',
@@ -1062,6 +1300,24 @@ module PWN
1062
1300
  port: 'optional - GQRX target port (defaults to 7356)'
1063
1301
  )
1064
1302
 
1303
+ udp_listener = #{self}.listen_udp(
1304
+ udp_ip: 'optional - IP address to bind UDP listener (defaults to 127.0.0.1)',
1305
+ udp_port: 'optional - Port to bind UDP listener (defaults to 7355)'
1306
+ )
1307
+
1308
+ #{self}.disconnect_udp(
1309
+ udp_listener: 'required - UDP socket object returned from #listen_udp method'
1310
+ )
1311
+
1312
+ iq_raw_file = #{self}.record(
1313
+ gqrx_sock: 'required - GQRX socket object returned from #connect method'
1314
+ )
1315
+
1316
+ #{self}.stop_recording(
1317
+ gqrx_sock: 'required - GQRX socket object returned from #connect method',
1318
+ iq_raw_file: 'required - iq_raw_file returned from #record method'
1319
+ )
1320
+
1065
1321
  #{self}.disconnect(
1066
1322
  gqrx_sock: 'required - GQRX socket object returned from #connect method'
1067
1323
  )