pwn 0.5.512 → 0.5.513

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
@@ -9,7 +9,7 @@ module PWN
9
9
  module GQRX
10
10
  # Monkey patches for frequency handling
11
11
  String.class_eval do
12
- def raw_hz
12
+ def cast_to_raw_hz
13
13
  gsub('.', '').to_i
14
14
  end
15
15
  end
@@ -20,67 +20,16 @@ module PWN
20
20
  # So 2_450_000_000 becomes 2.450.000.000
21
21
  # So 960_000_000 becomes 960.000.000
22
22
  # 1000 should be 1.000
23
- def pretty_hz
23
+ def cast_to_pretty_hz
24
24
  str_hz = to_s
25
25
  # Nuke leading zeros
26
26
  # E.g., 002450000000 -> 2450000000
27
- str_hz = str_hz.sub(/^0+/, '')
27
+ str_hz = str_hz.sub(/^0+/, '') unless str_hz == '0'
28
28
  # Insert dots every 3 digits from the right
29
29
  str_hz.reverse.scan(/.{1,3}/).join('.').reverse
30
30
  end
31
31
  end
32
32
 
33
- # Supported Method Parameters::
34
- # scan_resp = PWN::SDR::GQRX.log_signals(
35
- # signals_arr: 'required - Array of detected signals',
36
- # timestamp_start: 'required - Scan start timestamp',
37
- # scan_log: 'required - Path to save detected signals log'
38
- # )
39
- private_class_method def self.log_signals(opts = {})
40
- signals_arr = opts[:signals_arr]
41
- timestamp_start = opts[:timestamp_start]
42
- scan_log = opts[:scan_log]
43
-
44
- signals = signals_arr.sort_by { |s| s[:freq].to_s.raw_hz }
45
- timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
46
- duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
47
- # Convert duration seconds to hours minutes seconds
48
- hours = (duration_secs / 3600).to_i
49
- minutes = ((duration_secs % 3600) / 60).to_i
50
- seconds = (duration_secs % 60).to_i
51
- duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
52
-
53
- scan_resp = {
54
- signals: signals,
55
- timestamp_start: timestamp_start,
56
- timestamp_end: timestamp_end,
57
- duration: duration
58
- }
59
-
60
- File.write(
61
- scan_log,
62
- JSON.pretty_generate(scan_resp)
63
- )
64
-
65
- scan_resp
66
- rescue StandardError => e
67
- raise e
68
- end
69
-
70
- # Supported Method Parameters::
71
- # gqrx_sock = PWN::SDR::GQRX.connect(
72
- # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
73
- # port: 'optional - GQRX target port (defaults to 7356)'
74
- # )
75
- public_class_method def self.connect(opts = {})
76
- target = opts[:target] ||= '127.0.0.1'
77
- port = opts[:port] ||= 7356
78
-
79
- PWN::Plugins::Sock.connect(target: target, port: port)
80
- rescue StandardError => e
81
- raise e
82
- end
83
-
84
33
  # Supported Method Parameters::
85
34
  # gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
86
35
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
@@ -88,17 +37,22 @@ module PWN
88
37
  # resp_ok: 'optional - Expected response from GQRX to indicate success'
89
38
  # )
90
39
 
91
- public_class_method def self.gqrx_cmd(opts = {})
40
+ private_class_method def self.gqrx_cmd(opts = {})
92
41
  gqrx_sock = opts[:gqrx_sock]
93
42
  cmd = opts[:cmd]
94
43
  resp_ok = opts[:resp_ok]
95
44
 
96
45
  # Most Recent GQRX Command Set:
97
46
  # https://raw.githubusercontent.com/gqrx-sdr/gqrx/master/resources/remote-control.txt
47
+ # Remote control protocol.
48
+ #
98
49
  # Supported commands:
99
- # f Get frequency [Hz]
100
- # F <frequency> Set frequency [Hz]
101
- # m Get demodulator mode and passband
50
+ # f
51
+ # Get frequency [Hz]
52
+ # F <frequency>
53
+ # Set frequency [Hz]
54
+ # m
55
+ # Get demodulator mode and passband
102
56
  # M <mode> [passband]
103
57
  # Set demodulator mode and passband [Hz]
104
58
  # Passing a '?' as the first argument instead of 'mode' will return
@@ -121,18 +75,30 @@ module PWN
121
75
  # Set the value of the gain setting with the name <gain_name> to <value>
122
76
  # p RDS_PI
123
77
  # Get the RDS PI code (in hexadecimal). Returns 0000 if not applicable.
78
+ # p RDS_PS_NAME
79
+ # Get the RDS Program Service (PS) name
80
+ # p RDS_RADIOTEXT
81
+ # Get the RDS RadioText message
124
82
  # u RECORD
125
83
  # Get status of audio recorder
126
84
  # U RECORD <status>
127
85
  # Set status of audio recorder to <status>
86
+ # u IQRECORD
87
+ # Get status of IQ recorder
88
+ # U IQRECORD <status>
89
+ # Set status of IQ recorder to <status>
128
90
  # u DSP
129
91
  # Get DSP (SDR receiver) status
130
92
  # U DSP <status>
131
93
  # Set DSP (SDR receiver) status to <status>
132
94
  # u RDS
133
- # Get RDS decoder to <status>. Only functions in WFM mode.
95
+ # Get RDS decoder status. Only functions in WFM mode.
134
96
  # U RDS <status>
135
97
  # Set RDS decoder to <status>. Only functions in WFM mode.
98
+ # u MUTE
99
+ # Get audio mute status
100
+ # U MUTE <status>
101
+ # Set audio mute to <status>
136
102
  # q|Q
137
103
  # Close connection
138
104
  # AOS
@@ -159,6 +125,7 @@ module PWN
159
125
  # _
160
126
  # Get version
161
127
  #
128
+ #
162
129
  # Reply:
163
130
  # RPRT 0
164
131
  # Command successful
@@ -191,7 +158,7 @@ module PWN
191
158
  raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
192
159
 
193
160
  # Reformat positive integer frequency responses (e.g., from 'f')
194
- response_str = response_str.to_i.pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
161
+ response_str = response_str.to_i.cast_to_pretty_hz if response_str.match?(/^\d+$/) && response_str.to_i.positive?
195
162
 
196
163
  response_str
197
164
  rescue RuntimeError => e
@@ -206,11 +173,216 @@ module PWN
206
173
  raise e
207
174
  end
208
175
 
176
+ # Supported Method Parameters::
177
+ # strength_db = PWN::SDR::GQRX.measure_signal_strength(
178
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method'
179
+ # )
180
+ private_class_method def self.measure_signal_strength(opts = {})
181
+ gqrx_sock = opts[:gqrx_sock]
182
+
183
+ strength_db = -99.9
184
+ prev_strength_db = strength_db
185
+ # While strength_db is rising, keep measuring
186
+ loop do
187
+ strength_db = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
188
+ break if strength_db <= prev_strength_db
189
+
190
+ prev_strength_db = strength_db
191
+ end
192
+
193
+ strength_db
194
+ rescue StandardError => e
195
+ raise e
196
+ end
197
+
198
+ # Supported Method Parameters::
199
+ # candidate_signals = PWN::SDR::GQRX.edge_detection(
200
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
201
+ # hz: 'required - Frequency to start edge detection from',
202
+ # step_hz: 'required - Frequency step in Hz for edge detection',
203
+ # strength_lock: 'required - Strength lock in dBFS to determine signal edges'
204
+ # )
205
+ private_class_method def self.edge_detection(opts = {})
206
+ gqrx_sock = opts[:gqrx_sock]
207
+ hz = opts[:hz]
208
+ step_hz = opts[:step_hz]
209
+ strength_lock = opts[:strength_lock]
210
+ left_candidate_signals = []
211
+ right_candidate_signals = []
212
+ candidate_signals = []
213
+
214
+ # left_candidate_signals.clear
215
+ original_hz = hz
216
+ strength_db = 99.9
217
+ puts 'Finding Beginning Edge of Signal...'
218
+ while strength_db >= strength_lock
219
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
220
+ current_freq = 0
221
+ while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
222
+ current_freq = gqrx_cmd(
223
+ gqrx_sock: gqrx_sock,
224
+ cmd: 'f'
225
+ )
226
+ end
227
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
228
+ candidate = {
229
+ hz: hz.to_s.cast_to_raw_hz,
230
+ freq: hz.to_i.cast_to_pretty_hz,
231
+ strength: strength_db,
232
+ side: :left
233
+ }
234
+ left_candidate_signals.push(candidate)
235
+ hz -= step_hz
236
+ end
237
+ left_candidate_signals.uniq! { |s| s[:hz] }
238
+ left_candidate_signals.sort_by! { |s| s[:hz] }
239
+
240
+ # Now scan forwards to find the end of the signal
241
+ # The end of the signal is where the strength drops below strength_lock
242
+ # right_candidate_signals.clear
243
+ hz = original_hz
244
+
245
+ strength_db = 99.9
246
+ puts 'Finding Ending Edge of Signal...'
247
+ while strength_db >= strength_lock
248
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
249
+ current_freq = 0
250
+ while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
251
+ current_freq = gqrx_cmd(
252
+ gqrx_sock: gqrx_sock,
253
+ cmd: 'f'
254
+ )
255
+ end
256
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
257
+ candidate = {
258
+ hz: hz.to_s.cast_to_raw_hz,
259
+ freq: hz.to_i.cast_to_pretty_hz,
260
+ strength: strength_db,
261
+ side: :right
262
+ }
263
+ right_candidate_signals.push(candidate)
264
+ hz += step_hz
265
+ end
266
+ # Update candidate signals to remove duplicates and sort by hz
267
+ right_candidate_signals.uniq! { |s| s[:hz] }
268
+ right_candidate_signals.sort_by! { |s| s[:hz] }
269
+
270
+ candidate_signals = left_candidate_signals + right_candidate_signals
271
+ candidate_signals.uniq! { |s| s[:hz] }
272
+ candidate_signals.sort_by! { |s| s[:hz] }
273
+ rescue StandardError => e
274
+ raise e
275
+ end
276
+
277
+ # Supported Method Parameters::
278
+ # scan_resp = PWN::SDR::GQRX.log_signals(
279
+ # signals_arr: 'required - Array of detected signals',
280
+ # timestamp_start: 'required - Scan start timestamp',
281
+ # scan_log: 'required - Path to save detected signals log'
282
+ # )
283
+ private_class_method def self.log_signals(opts = {})
284
+ signals_arr = opts[:signals_arr]
285
+ timestamp_start = opts[:timestamp_start]
286
+ scan_log = opts[:scan_log]
287
+
288
+ signals = signals_arr.sort_by { |s| s[:freq].to_s.cast_to_raw_hz }
289
+ # Unique signals by frequency
290
+ signals.uniq! { |s| s[:hz] }
291
+
292
+ timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
293
+ duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
294
+ # Convert duration seconds to hours minutes seconds
295
+ hours = (duration_secs / 3600).to_i
296
+ minutes = ((duration_secs % 3600) / 60).to_i
297
+ seconds = (duration_secs % 60).to_i
298
+ duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
299
+
300
+ scan_resp = {
301
+ signals: signals,
302
+ total: signals.length,
303
+ timestamp_start: timestamp_start,
304
+ timestamp_end: timestamp_end,
305
+ duration: duration
306
+ }
307
+
308
+ File.write(
309
+ scan_log,
310
+ JSON.pretty_generate(scan_resp)
311
+ )
312
+
313
+ scan_resp
314
+ rescue StandardError => e
315
+ raise e
316
+ end
317
+
318
+ # Supported Method Parameters::
319
+ # rds_resp = PWN::SDR::GQRX.decode_rds(
320
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method'
321
+ # )
322
+
323
+ private_class_method def self.decode_rds(opts = {})
324
+ gqrx_sock = opts[:gqrx_sock]
325
+
326
+ # We toggle RDS off and on to reset the decoder
327
+ rds_resp = gqrx_cmd(
328
+ gqrx_sock: gqrx_sock,
329
+ cmd: 'U RDS 0',
330
+ resp_ok: 'RPRT 0'
331
+ )
332
+
333
+ rds_resp = gqrx_cmd(
334
+ gqrx_sock: gqrx_sock,
335
+ cmd: 'U RDS 1',
336
+ resp_ok: 'RPRT 0'
337
+ )
338
+
339
+ rds_resp = {}
340
+ attempts = 0
341
+ max_attempts = 90
342
+ skip_rds = "\n"
343
+ print 'INFO: Decoding FM radio RDS data (Press ENTER to skip)...'
344
+ max_attempts.times do
345
+ attempts += 1
346
+ rds_resp[:rds_pi] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PI')
347
+ rds_resp[:rds_ps_name] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_PS_NAME')
348
+ rds_resp[:rds_radiotext] = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'p RDS_RADIOTEXT')
349
+
350
+ # Break if ENTER key pressed
351
+ # This is useful if no RDS data is available
352
+ # on the current frequency (e.g. false+)
353
+ break if $stdin.ready? && $stdin.read_nonblock(1) == skip_rds
354
+
355
+ break if rds_resp[:rds_pi] != '0000' && !rds_resp[:rds_ps_name].empty? && !rds_resp[:rds_radiotext].empty?
356
+
357
+ print '.'
358
+ sleep 0.1
359
+ end
360
+ puts 'complete.'
361
+ rds_resp
362
+ rescue StandardError => e
363
+ raise e
364
+ end
365
+
366
+ # Supported Method Parameters::
367
+ # gqrx_sock = PWN::SDR::GQRX.connect(
368
+ # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
369
+ # port: 'optional - GQRX target port (defaults to 7356)'
370
+ # )
371
+ public_class_method def self.connect(opts = {})
372
+ target = opts[:target] ||= '127.0.0.1'
373
+ port = opts[:port] ||= 7356
374
+
375
+ PWN::Plugins::Sock.connect(target: target, port: port)
376
+ rescue StandardError => e
377
+ raise e
378
+ end
379
+
209
380
  # Supported Method Parameters::
210
381
  # freq_obj = PWN::SDR::GQRX.init_freq(
211
382
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
212
383
  # freq: 'required - Frequency to set',
213
384
  # demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
385
+ # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
214
386
  # bandwidth: 'optional - Bandwidth (defaults to 200_000)',
215
387
  # squelch: 'optional - Squelch level to set (Defaults to current value)',
216
388
  # decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
@@ -239,6 +411,8 @@ module PWN
239
411
  demodulator_mode = opts[:demodulator_mode] ||= :WFM
240
412
  raise "ERROR: Invalid demodulator_mode '#{demodulator_mode}'. Valid modes: #{valid_demodulator_modes.join(', ')}" unless valid_demodulator_modes.include?(demodulator_mode.to_sym)
241
413
 
414
+ rds = opts[:rds] ||= false
415
+
242
416
  bandwidth = opts[:bandwidth] ||= 200_000
243
417
  squelch = opts[:squelch]
244
418
  decoder = opts[:decoder]
@@ -248,24 +422,16 @@ module PWN
248
422
 
249
423
  raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
250
424
 
251
- if squelch.is_a?(Float) && squelch >= -100.0 && squelch <= 0.0 && !keep_alive
425
+ unless keep_alive
426
+ squelch = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l SQL').to_f if squelch.nil?
252
427
  change_squelch_resp = gqrx_cmd(
253
428
  gqrx_sock: gqrx_sock,
254
429
  cmd: "L SQL #{squelch}",
255
430
  resp_ok: 'RPRT 0'
256
431
  )
257
- end
258
-
259
- change_freq_resp = gqrx_cmd(
260
- gqrx_sock: gqrx_sock,
261
- cmd: "F #{freq.to_s.raw_hz}",
262
- resp_ok: 'RPRT 0'
263
- )
264
432
 
265
- # Set demod mode and bandwidth (always, using defaults if not provided)
266
- unless keep_alive
267
433
  mode_str = demodulator_mode.to_s.upcase
268
- passband_hz = bandwidth.to_s.raw_hz
434
+ passband_hz = bandwidth.to_s.cast_to_raw_hz
269
435
  gqrx_cmd(
270
436
  gqrx_sock: gqrx_sock,
271
437
  cmd: "M #{mode_str} #{passband_hz}",
@@ -273,38 +439,41 @@ module PWN
273
439
  )
274
440
  end
275
441
 
276
- # Get demodulator mode n passband
277
- demod_n_passband = gqrx_cmd(
442
+ change_freq_resp = gqrx_cmd(
278
443
  gqrx_sock: gqrx_sock,
279
- cmd: 'm'
444
+ cmd: "F #{freq.to_s.cast_to_raw_hz}",
445
+ resp_ok: 'RPRT 0'
280
446
  )
281
447
 
282
- # Get current frequency
283
- current_freq = gqrx_cmd(
284
- gqrx_sock: gqrx_sock,
285
- cmd: 'f'
286
- )
448
+ current_freq = 0
449
+ while current_freq.to_s.cast_to_raw_hz != freq.to_s.cast_to_raw_hz
450
+ current_freq = gqrx_cmd(
451
+ gqrx_sock: gqrx_sock,
452
+ cmd: 'f'
453
+ )
454
+ end
287
455
 
288
456
  freq_obj = {
457
+ bandwidth: bandwidth,
289
458
  demodulator_mode: demodulator_mode,
290
- demod_mode_n_passband: demod_n_passband,
291
- freq: current_freq,
292
- bandwidth: bandwidth
459
+ rds: rds,
460
+ freq: freq
293
461
  }
294
462
 
295
463
  unless suppress_details
464
+ demod_n_passband = gqrx_cmd(
465
+ gqrx_sock: gqrx_sock,
466
+ cmd: 'm'
467
+ )
468
+
296
469
  audio_gain_db = gqrx_cmd(
297
470
  gqrx_sock: gqrx_sock,
298
471
  cmd: 'l AF'
299
472
  ).to_f
300
473
 
301
- strength_db_float = gqrx_cmd(
302
- gqrx_sock: gqrx_sock,
303
- cmd: 'l STRENGTH'
304
- ).to_f
305
- strength_db = strength_db_float.round(1)
474
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
306
475
 
307
- current_squelch = gqrx_cmd(
476
+ squelch = gqrx_cmd(
308
477
  gqrx_sock: gqrx_sock,
309
478
  cmd: 'l SQL'
310
479
  ).to_f
@@ -324,12 +493,17 @@ module PWN
324
493
  cmd: 'l BB_GAIN'
325
494
  )
326
495
 
496
+ rds_resp = nil
497
+ rds_resp = decode_rds(gqrx_sock: gqrx_sock) if rds
498
+
327
499
  freq_obj[:audio_gain_db] = audio_gain_db
328
- freq_obj[:squelch_set] = current_squelch
329
- freq_obj[:rf_gain] = rf_gain
330
- freq_obj[:if_gain] = if_gain
500
+ freq_obj[:demod_mode_n_passband] = demod_n_passband
331
501
  freq_obj[:bb_gain] = bb_gain
502
+ freq_obj[:if_gain] = if_gain
503
+ freq_obj[:rf_gain] = rf_gain
504
+ freq_obj[:squelch] = squelch
332
505
  freq_obj[:strength_db] = strength_db
506
+ freq_obj[:rds] = rds_resp
333
507
  end
334
508
 
335
509
  # Start recording and decoding if decoder provided
@@ -365,6 +539,7 @@ module PWN
365
539
  decoder_thread = decoder_module.start(freq_obj: freq_obj)
366
540
  freq_obj.delete(:gqrx_sock)
367
541
 
542
+ freq_obj[:freq] = current_freq
368
543
  freq_obj[:decoder] = decoder
369
544
  freq_obj[:decoder_module] = decoder_module
370
545
  freq_obj[:decoder_thread] = decoder_thread
@@ -393,10 +568,9 @@ module PWN
393
568
  # start_freq: 'required - Start frequency of scan range',
394
569
  # target_freq: 'required - Target frequency of scan range',
395
570
  # demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
571
+ # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
396
572
  # bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
397
- # overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
398
573
  # precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
399
- # lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
400
574
  # strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
401
575
  # squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
402
576
  # audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
@@ -414,19 +588,19 @@ module PWN
414
588
  gqrx_sock = opts[:gqrx_sock]
415
589
 
416
590
  start_freq = opts[:start_freq]
417
- hz_start = start_freq.to_s.raw_hz
591
+ hz_start = start_freq.to_s.cast_to_raw_hz
418
592
 
419
593
  target_freq = opts[:target_freq]
420
- hz_target = target_freq.to_s.raw_hz
594
+ hz_target = target_freq.to_s.cast_to_raw_hz
421
595
 
422
596
  demodulator_mode = opts[:demodulator_mode]
597
+ rds = opts[:rds] ||= false
598
+
423
599
  bandwidth = opts[:bandwidth] ||= 200_000
424
- overlap_protection = opts[:overlap_protection] || false
425
600
  precision = opts[:precision] ||= 1
426
- lock_freq_duration = opts[:lock_freq_duration] ||= 0.04
427
601
  strength_lock = opts[:strength_lock] ||= -70.0
428
602
  squelch = opts[:squelch] ||= (strength_lock - 3.0)
429
- scan_log = opts[:scan_log] ||= "/tmp/pwn_sdr_gqrx_scan_#{hz_start.pretty_hz}-#{hz_target.pretty_hz}_#{log_timestamp}.json"
603
+ 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"
430
604
  location = opts[:location] ||= 'United States'
431
605
 
432
606
  step_hz = 10**(precision - 1)
@@ -439,9 +613,20 @@ module PWN
439
613
  resp_ok: 'RPRT 0'
440
614
  )
441
615
 
616
+ # We always disable RDS decoding at during the scan
617
+ # to prevent unnecessary processing overhead.
618
+ # We return the rds boolean in the scan_resp object
619
+ # so it will be picked up and used appropriately
620
+ # when calling analyze_scan or analyze_log methods.
621
+ rds_resp = gqrx_cmd(
622
+ gqrx_sock: gqrx_sock,
623
+ cmd: 'U RDS 0',
624
+ resp_ok: 'RPRT 0'
625
+ )
626
+
442
627
  # Set demodulator mode & passband once for the scan
443
628
  mode_str = demodulator_mode.to_s.upcase
444
- passband_hz = bandwidth.to_s.raw_hz
629
+ passband_hz = bandwidth.to_s.cast_to_raw_hz
445
630
  gqrx_cmd(
446
631
  gqrx_sock: gqrx_sock,
447
632
  cmd: "M #{mode_str} #{passband_hz}",
@@ -450,7 +635,7 @@ module PWN
450
635
 
451
636
  audio_gain_db = opts[:audio_gain_db] ||= 6.0
452
637
  audio_gain_db = audio_gain_db.to_f
453
- audio_gain_db_resp = PWN::SDR::GQRX.gqrx_cmd(
638
+ audio_gain_db_resp = gqrx_cmd(
454
639
  gqrx_sock: gqrx_sock,
455
640
  cmd: "L AF #{audio_gain_db}",
456
641
  resp_ok: 'RPRT 0'
@@ -458,7 +643,7 @@ module PWN
458
643
 
459
644
  rf_gain = opts[:rf_gain] ||= 0.0
460
645
  rf_gain = rf_gain.to_f
461
- rf_gain_resp = PWN::SDR::GQRX.gqrx_cmd(
646
+ rf_gain_resp = gqrx_cmd(
462
647
  gqrx_sock: gqrx_sock,
463
648
  cmd: "L RF_GAIN #{rf_gain}",
464
649
  resp_ok: 'RPRT 0'
@@ -466,7 +651,7 @@ module PWN
466
651
 
467
652
  intermediate_gain = opts[:intermediate_gain] ||= 32.0
468
653
  intermediate_gain = intermediate_gain.to_f
469
- intermediate_resp = PWN::SDR::GQRX.gqrx_cmd(
654
+ intermediate_resp = gqrx_cmd(
470
655
  gqrx_sock: gqrx_sock,
471
656
  cmd: "L IF_GAIN #{intermediate_gain}",
472
657
  resp_ok: 'RPRT 0'
@@ -474,35 +659,33 @@ module PWN
474
659
 
475
660
  baseband_gain = opts[:baseband_gain] ||= 10.0
476
661
  baseband_gain = baseband_gain.to_f
477
- baseband_resp = PWN::SDR::GQRX.gqrx_cmd(
662
+ baseband_resp = gqrx_cmd(
478
663
  gqrx_sock: gqrx_sock,
479
664
  cmd: "L BB_GAIN #{baseband_gain}",
480
665
  resp_ok: 'RPRT 0'
481
666
  )
482
667
 
483
- prev_freq_obj = {}
668
+ prev_freq_obj = init_freq(
669
+ gqrx_sock: gqrx_sock,
670
+ freq: hz_start,
671
+ demodulator_mode: demodulator_mode,
672
+ rds: rds,
673
+ bandwidth: bandwidth,
674
+ squelch: squelch,
675
+ suppress_details: true,
676
+ keep_alive: true
677
+ )
484
678
 
485
- in_signal = false
486
679
  candidate_signals = []
487
- strength_history = []
488
680
 
489
681
  # Adaptive peak finder – trims weakest ends after each pass
490
682
  # Converges quickly to the true center of the bell curve
491
683
  find_best_peak = lambda do |opts = {}|
492
- beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.raw_hz
493
- top_of_signal_hz = opts[:top_of_signal_hz].to_s.raw_hz
494
- end_of_signal_hz = top_of_signal_hz + step_hz
495
-
496
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
497
- # puts "Current Frequency: #{current_hz.pretty_hz}"
498
- puts "Signal Began: #{beg_of_signal_hz.pretty_hz}"
499
- puts "Signal Appeared to Peak at: #{top_of_signal_hz.pretty_hz}"
500
- puts "Calculated Signal End: #{end_of_signal_hz.pretty_hz}"
501
- # steps_between_beg_n_end = ((end_of_signal_hz - beg_of_signal_hz) / step_hz).abs
502
- # puts steps_between_beg_n_end.inspect
684
+ beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.cast_to_raw_hz
685
+ end_of_signal_hz = opts[:end_of_signal_hz].to_s.cast_to_raw_hz
503
686
 
504
687
  samples = []
505
- prev_best_sample = nil
688
+ prev_best_sample = {}
506
689
  consecutive_best = 0
507
690
  direction_up = true
508
691
 
@@ -525,19 +708,19 @@ module PWN
525
708
  print '>' if direction_up
526
709
  print '<' unless direction_up
527
710
  gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
528
- sleep lock_freq_duration
529
- strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
530
- strength_db = strength_db_float.round(1)
711
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
531
712
  samples.push({ hz: hz, strength_db: strength_db })
532
713
 
533
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
534
- # puts "Sampled Frequency: #{current_hz.pretty_hz} => Strength: #{strength_db} dBFS"
714
+ # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.cast_to_raw_hz
715
+ # puts "Sampled Frequency: #{current_hz.to_i.cast_to_pretty_hz} => Strength: #{strength_db} dBFS"
535
716
  end
536
717
 
537
718
  # Compute fresh averaged_samples from all cumulative samples
538
719
  averaged_samples = []
539
720
  samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
540
- avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
721
+ avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size)
722
+ # avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(2)
723
+ # avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
541
724
  averaged_samples.push({ hz: hz, strength_db: avg_strength })
542
725
  end
543
726
 
@@ -567,7 +750,7 @@ module PWN
567
750
  best_sample = averaged_samples.max_by { |s| s[:strength_db] }
568
751
 
569
752
  # Check for improvement
570
- if best_sample == prev_best_sample
753
+ if best_sample[:hz] == prev_best_sample[:hz]
571
754
  consecutive_best += 1
572
755
  else
573
756
  consecutive_best = 0
@@ -576,7 +759,7 @@ module PWN
576
759
  # Dup to avoid reference issues
577
760
  prev_best_sample = best_sample.dup
578
761
 
579
- puts "Pass #{pass_count}: Best #{best_sample[:hz].pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
762
+ puts "Pass #{pass_count}: Best #{best_sample[:hz].to_i.cast_to_pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
580
763
 
581
764
  # Break if no improvement in 3 consecutive passes or theres only one sample left
582
765
  break if consecutive_best.positive? || averaged_samples.size == 1
@@ -586,82 +769,101 @@ module PWN
586
769
  end
587
770
 
588
771
  # Begin scanning range
589
- puts "INFO: Scanning from #{hz_start.pretty_hz} to #{hz_target.pretty_hz} in steps of #{step.abs.pretty_hz} Hz.\nIf scans are slow and/or you're experiencing false positives/negatives, consider adjusting:\n1. The SDR's sample rate in GQRX\n\s\s- Click on `Configure I/O devices`.\n\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000).\n2. Adjust the :strength_lock parameter.\n3. Adjust the :lock_freq_duration parameter.\n4. Adjust the :precision parameter.\n5. Disable AI introspection in PWN::Env\nHappy scanning!\n\n"
772
+ puts "\n"
773
+ puts '-' * 86
774
+ 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."
775
+ puts "If scans are slow and/or you're experiencing false positives/negatives,"
776
+ puts 'consider adjusting the following:'
777
+ puts "1. The SDR's sample rate in GQRX"
778
+ puts "\s\s- Click on `Configure I/O devices`."
779
+ puts "\s\s- A lower `Input rate` value seems counter-intuitive but works well (e.g. ADALM PLUTO ~ 1000000)."
780
+ puts '2. Adjust the :strength_lock parameter.'
781
+ puts '3. Adjust the :precision parameter.'
782
+ puts '4. Disable AI introspection in PWN::Env'
783
+ puts 'Happy scanning!'
784
+ puts '-' * 86
785
+ puts "\n\n\n"
590
786
 
591
787
  signals_arr = []
592
- # Format timestamp_start for filename
593
- hz_start.step(by: step, to: hz_target) do |hz|
788
+ hz = hz_start
789
+ while hz <= hz_target
594
790
  gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
595
- sleep lock_freq_duration
596
- strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
597
- strength_db = strength_db_float.round(1)
598
- prev_strength_db = strength_history.last || -Float::INFINITY
599
-
600
- if strength_db >= strength_lock && strength_db > prev_strength_db
601
- in_signal = true
602
- strength_history.push(strength_db)
603
- strength_history.shift if strength_history.size > 5
604
- current_strength = (strength_history.sum / strength_history.size).round(1)
605
-
606
- print '.'
607
- puts "#{hz.pretty_hz} => #{strength_db}"
608
-
609
- candidate = { hz: hz, freq: hz.pretty_hz, strength: current_strength }
610
- candidate_signals.push(candidate)
611
- else
612
- if in_signal
613
- beg_of_signal_hz = candidate_signals.map { |s| s[:hz] }.min
614
- # Previous max step_hz was actually the top of the signal
615
- top_of_signal_hz = candidate_signals.map { |s| s[:hz] }.max - step_hz
616
-
617
- skip_signal = false
618
- prev_freq = prev_freq_obj[:freq].to_s.raw_hz
619
- distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_freq).abs
620
- half_bandwidth = (bandwidth / 2).to_i
621
- skip_signal = true if distance_from_prev_detected_freq_hz < half_bandwidth && overlap_protection
622
- puts "Prev Dect Freq: #{prev_freq} | New Freq Edge: #{beg_of_signal_hz} | Distance from Prev Dect Freq: #{distance_from_prev_detected_freq_hz} Hz | Step Hz: #{step_hz} | Bandwidth: #{bandwidth} Hz | Half Bandwidth: #{half_bandwidth} Hz | Overlap Protection? #{overlap_protection} | Skip Signal? #{skip_signal}"
623
- next if skip_signal
624
-
625
- best_peak = find_best_peak.call(
626
- beg_of_signal_hz: beg_of_signal_hz,
627
- top_of_signal_hz: top_of_signal_hz
791
+ current_freq = 0
792
+ while current_freq.to_s.cast_to_raw_hz != hz.to_s.cast_to_raw_hz
793
+ current_freq = gqrx_cmd(
794
+ gqrx_sock: gqrx_sock,
795
+ cmd: 'f'
796
+ )
797
+ end
798
+
799
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
800
+
801
+ if strength_db >= strength_lock
802
+ puts '-' * 86
803
+ # Find left and right edges of the signal
804
+ candidate_signals = edge_detection(
805
+ gqrx_sock: gqrx_sock,
806
+ hz: hz,
807
+ step_hz: step_hz,
808
+ strength_lock: strength_lock
809
+ )
810
+ elsif candidate_signals.length.positive?
811
+ beg_of_signal_hz = candidate_signals.first[:hz]
812
+ top_of_signal_hz_idx = (candidate_signals.length - 1) / 2
813
+ top_of_signal_hz = candidate_signals[top_of_signal_hz_idx][:hz]
814
+ end_of_signal_hz = candidate_signals.last[:hz]
815
+ puts 'Candidate Signal(s) Detected:'
816
+ puts JSON.pretty_generate(candidate_signals)
817
+
818
+ prev_freq = prev_freq_obj[:freq].to_s.cast_to_raw_hz
819
+ distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_freq).abs
820
+ half_bandwidth = (bandwidth / 2).to_i
821
+
822
+ 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"
823
+
824
+ puts 'Finding Best Peak...'
825
+ best_peak = find_best_peak.call(
826
+ beg_of_signal_hz: beg_of_signal_hz,
827
+ end_of_signal_hz: end_of_signal_hz
828
+ )
829
+
830
+ if best_peak[:hz] && best_peak[:strength_db] > strength_lock
831
+ puts "\n**** Detected Signal ****"
832
+ best_freq = best_peak[:hz].to_i.cast_to_pretty_hz
833
+ best_strength_db = best_peak[:strength_db]
834
+ prev_freq_obj = init_freq(
835
+ gqrx_sock: gqrx_sock,
836
+ freq: best_freq,
837
+ rds: rds,
838
+ suppress_details: true,
839
+ keep_alive: true
840
+ )
841
+ prev_freq_obj[:strength_lock] = strength_lock
842
+ prev_freq_obj[:strength_db] = best_strength_db
843
+
844
+ 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."
845
+ ai_analysis = PWN::AI::Introspection.reflect_on(
846
+ request: prev_freq_obj.to_json,
847
+ system_role_content: system_role_content,
848
+ suppress_pii_warning: true
628
849
  )
629
850
 
630
- if best_peak[:hz] && best_peak[:strength_db] > strength_lock
631
- prev_freq_obj = init_freq(
632
- gqrx_sock: gqrx_sock,
633
- freq: best_peak[:hz],
634
- demodulator_mode: demodulator_mode,
635
- bandwidth: bandwidth,
636
- squelch: squelch,
637
- suppress_details: true,
638
- keep_alive: true
639
- )
640
- prev_freq_obj[:lock_freq_duration] = lock_freq_duration
641
- prev_freq_obj[:strength_lock] = strength_lock
642
-
643
- 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."
644
- ai_analysis = PWN::AI::Introspection.reflect_on(
645
- request: prev_freq_obj.to_json,
646
- system_role_content: system_role_content,
647
- suppress_pii_warning: true
648
- )
649
- prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
650
- puts "\n**** Detected Signal ****"
651
- puts JSON.pretty_generate(prev_freq_obj)
652
- signals_arr.push(prev_freq_obj)
653
- log_signals(
654
- signals_arr: signals_arr,
655
- timestamp_start: timestamp_start,
656
- scan_log: scan_log
657
- )
658
- end
659
- candidate_signals.clear
660
- sleep lock_freq_duration
851
+ prev_freq_obj[:ai_analysis] = ai_analysis unless ai_analysis.nil?
852
+ puts JSON.pretty_generate(prev_freq_obj)
853
+ puts '-' * 86
854
+ puts "\n\n\n"
855
+ signals_arr.push(prev_freq_obj)
856
+ log_signals(
857
+ signals_arr: signals_arr,
858
+ timestamp_start: timestamp_start,
859
+ scan_log: scan_log
860
+ )
861
+ hz = end_of_signal_hz
862
+ # gets
661
863
  end
662
- in_signal = false
663
- strength_history = []
864
+ candidate_signals.clear
664
865
  end
866
+ hz += step_hz
665
867
  end
666
868
 
667
869
  log_signals(
@@ -669,6 +871,8 @@ module PWN
669
871
  timestamp_start: timestamp_start,
670
872
  scan_log: scan_log
671
873
  )
874
+ rescue Interrupt
875
+ puts "\nCTRL+C detected - goodbye."
672
876
  rescue StandardError => e
673
877
  raise e
674
878
  ensure
@@ -693,9 +897,11 @@ module PWN
693
897
  )
694
898
 
695
899
  scan_resp[:signals].each do |signal|
696
- freq_obj = { gqrx_sock: gqrx_sock, keep_alive: true }
900
+ signal[:gqrx_sock] = gqrx_sock
901
+ # This is required to keep connection alive during analysis
902
+ signal[:keep_alive] = true
903
+ freq_obj = init_freq(signal)
697
904
  freq_obj = signal.merge(freq_obj)
698
- freq_obj = init_freq(freq_obj)
699
905
  # Redact gqrx_sock from output
700
906
  freq_obj.delete(:gqrx_sock)
701
907
  puts JSON.pretty_generate(freq_obj)
@@ -703,6 +909,8 @@ module PWN
703
909
  gets
704
910
  puts "\n" * 3
705
911
  end
912
+ rescue Interrupt
913
+ puts "\nCTRL+C detected - goodbye."
706
914
  rescue StandardError => e
707
915
  raise e
708
916
  ensure
@@ -741,7 +949,7 @@ module PWN
741
949
  public_class_method def self.disconnect(opts = {})
742
950
  gqrx_sock = opts[:gqrx_sock]
743
951
 
744
- PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock)
952
+ PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock) unless gqrx_sock.closed?
745
953
  rescue StandardError => e
746
954
  raise e
747
955
  end
@@ -763,16 +971,11 @@ module PWN
763
971
  port: 'optional - GQRX target port (defaults to 7356)'
764
972
  )
765
973
 
766
- gqrx_resp = #{self}.gqrx_cmd(
767
- gqrx_sock: 'required - GQRX socket object returned from #connect method',
768
- cmd: 'required - GQRX command to execute',
769
- resp_ok: 'optional - Expected response from GQRX to indicate success'
770
- )
771
-
772
974
  freq_obj = #{self}.init_freq(
773
975
  gqrx_sock: 'required - GQRX socket object returned from #connect method',
774
976
  freq: 'required - Frequency to set',
775
977
  demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
978
+ rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
776
979
  bandwidth: 'optional - Bandwidth (defaults to 200_000)',
777
980
  decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
778
981
  record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
@@ -786,9 +989,7 @@ module PWN
786
989
  target_freq: 'required - Target frequency',
787
990
  demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
788
991
  bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
789
- overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
790
992
  precision: 'optional - Precision (Defaults to 1)',
791
- lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
792
993
  strength_lock: 'optional - Strength lock (defaults to -70.0)',
793
994
  squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
794
995
  audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',