pwn 0.5.510 → 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,30 +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
- # gqrx_sock = PWN::SDR::GQRX.connect(
35
- # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
36
- # port: 'optional - GQRX target port (defaults to 7356)'
37
- # )
38
- public_class_method def self.connect(opts = {})
39
- target = opts[:target] ||= '127.0.0.1'
40
- port = opts[:port] ||= 7356
41
-
42
- PWN::Plugins::Sock.connect(target: target, port: port)
43
- rescue StandardError => e
44
- raise e
45
- end
46
-
47
33
  # Supported Method Parameters::
48
34
  # gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
49
35
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
@@ -51,17 +37,22 @@ module PWN
51
37
  # resp_ok: 'optional - Expected response from GQRX to indicate success'
52
38
  # )
53
39
 
54
- public_class_method def self.gqrx_cmd(opts = {})
40
+ private_class_method def self.gqrx_cmd(opts = {})
55
41
  gqrx_sock = opts[:gqrx_sock]
56
42
  cmd = opts[:cmd]
57
43
  resp_ok = opts[:resp_ok]
58
44
 
59
45
  # Most Recent GQRX Command Set:
60
46
  # https://raw.githubusercontent.com/gqrx-sdr/gqrx/master/resources/remote-control.txt
47
+ # Remote control protocol.
48
+ #
61
49
  # Supported commands:
62
- # f Get frequency [Hz]
63
- # F <frequency> Set frequency [Hz]
64
- # 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
65
56
  # M <mode> [passband]
66
57
  # Set demodulator mode and passband [Hz]
67
58
  # Passing a '?' as the first argument instead of 'mode' will return
@@ -84,18 +75,30 @@ module PWN
84
75
  # Set the value of the gain setting with the name <gain_name> to <value>
85
76
  # p RDS_PI
86
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
87
82
  # u RECORD
88
83
  # Get status of audio recorder
89
84
  # U RECORD <status>
90
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>
91
90
  # u DSP
92
91
  # Get DSP (SDR receiver) status
93
92
  # U DSP <status>
94
93
  # Set DSP (SDR receiver) status to <status>
95
94
  # u RDS
96
- # Get RDS decoder to <status>. Only functions in WFM mode.
95
+ # Get RDS decoder status. Only functions in WFM mode.
97
96
  # U RDS <status>
98
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>
99
102
  # q|Q
100
103
  # Close connection
101
104
  # AOS
@@ -122,6 +125,7 @@ module PWN
122
125
  # _
123
126
  # Get version
124
127
  #
128
+ #
125
129
  # Reply:
126
130
  # RPRT 0
127
131
  # Command successful
@@ -154,7 +158,7 @@ module PWN
154
158
  raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
155
159
 
156
160
  # Reformat positive integer frequency responses (e.g., from 'f')
157
- 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?
158
162
 
159
163
  response_str
160
164
  rescue RuntimeError => e
@@ -170,86 +174,306 @@ module PWN
170
174
  end
171
175
 
172
176
  # Supported Method Parameters::
173
- # init_freq_hash = PWN::SDR::GQRX.init_freq(
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
+
380
+ # Supported Method Parameters::
381
+ # freq_obj = PWN::SDR::GQRX.init_freq(
174
382
  # gqrx_sock: 'required - GQRX socket object returned from #connect method',
175
383
  # freq: 'required - Frequency to set',
176
384
  # demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
385
+ # rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
177
386
  # bandwidth: 'optional - Bandwidth (defaults to 200_000)',
178
387
  # squelch: 'optional - Squelch level to set (Defaults to current value)',
179
388
  # decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
180
- # record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to Dir.home)',
181
- # decoder_opts: 'optional - Hash of additional options for the decoder',
182
- # suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)'
389
+ # record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
390
+ # suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
391
+ # keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
183
392
  # )
184
393
  public_class_method def self.init_freq(opts = {})
185
394
  gqrx_sock = opts[:gqrx_sock]
186
395
  freq = opts[:freq]
187
- demodulator_mode = opts[:demodulator_mode] ||= 'WFM'
396
+ valid_demodulator_modes = %i[
397
+ AM
398
+ AM_SYNC
399
+ CW
400
+ CWL
401
+ CWU
402
+ FM
403
+ OFF
404
+ LSB
405
+ RAW
406
+ USB
407
+ WFM
408
+ WFM_ST
409
+ WFM_ST_OIRT
410
+ ]
411
+ demodulator_mode = opts[:demodulator_mode] ||= :WFM
412
+ raise "ERROR: Invalid demodulator_mode '#{demodulator_mode}'. Valid modes: #{valid_demodulator_modes.join(', ')}" unless valid_demodulator_modes.include?(demodulator_mode.to_sym)
413
+
414
+ rds = opts[:rds] ||= false
415
+
188
416
  bandwidth = opts[:bandwidth] ||= 200_000
189
417
  squelch = opts[:squelch]
190
418
  decoder = opts[:decoder]
191
- record_dir = opts[:record_dir] ||= Dir.home
192
- decoder_opts = opts[:decoder_opts] ||= {}
419
+ record_dir = opts[:record_dir] ||= '/tmp'
193
420
  suppress_details = opts[:suppress_details] || false
421
+ keep_alive = opts[:keep_alive] || false
194
422
 
195
423
  raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
196
424
 
197
- hz = freq.to_s.raw_hz
198
-
199
- if squelch.is_a?(Float) && squelch >= -100.0 && squelch <= 0.0
425
+ unless keep_alive
426
+ squelch = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l SQL').to_f if squelch.nil?
200
427
  change_squelch_resp = gqrx_cmd(
201
428
  gqrx_sock: gqrx_sock,
202
429
  cmd: "L SQL #{squelch}",
203
430
  resp_ok: 'RPRT 0'
204
431
  )
432
+
433
+ mode_str = demodulator_mode.to_s.upcase
434
+ passband_hz = bandwidth.to_s.cast_to_raw_hz
435
+ gqrx_cmd(
436
+ gqrx_sock: gqrx_sock,
437
+ cmd: "M #{mode_str} #{passband_hz}",
438
+ resp_ok: 'RPRT 0'
439
+ )
205
440
  end
206
441
 
207
442
  change_freq_resp = gqrx_cmd(
208
443
  gqrx_sock: gqrx_sock,
209
- cmd: "F #{hz}",
210
- resp_ok: 'RPRT 0'
211
- )
212
-
213
- # Set demod mode and bandwidth (always, using defaults if not provided)
214
- mode_str = demodulator_mode.to_s.upcase
215
- passband_hz = bandwidth.to_s.raw_hz
216
- gqrx_cmd(
217
- gqrx_sock: gqrx_sock,
218
- cmd: "M #{mode_str} #{passband_hz}",
444
+ cmd: "F #{freq.to_s.cast_to_raw_hz}",
219
445
  resp_ok: 'RPRT 0'
220
446
  )
221
447
 
222
- # Get demodulator mode n passband
223
- demod_n_passband = gqrx_cmd(
224
- gqrx_sock: gqrx_sock,
225
- cmd: 'm'
226
- )
227
-
228
- # Get current frequency
229
- current_freq = gqrx_cmd(
230
- gqrx_sock: gqrx_sock,
231
- cmd: 'f'
232
- )
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
233
455
 
234
- init_freq_hash = {
235
- demod_mode_n_passband: demod_n_passband,
236
- frequency: current_freq,
237
- bandwidth: bandwidth
456
+ freq_obj = {
457
+ bandwidth: bandwidth,
458
+ demodulator_mode: demodulator_mode,
459
+ rds: rds,
460
+ freq: freq
238
461
  }
239
462
 
240
463
  unless suppress_details
464
+ demod_n_passband = gqrx_cmd(
465
+ gqrx_sock: gqrx_sock,
466
+ cmd: 'm'
467
+ )
468
+
241
469
  audio_gain_db = gqrx_cmd(
242
470
  gqrx_sock: gqrx_sock,
243
471
  cmd: 'l AF'
244
472
  ).to_f
245
473
 
246
- strength_db_float = gqrx_cmd(
247
- gqrx_sock: gqrx_sock,
248
- cmd: 'l STRENGTH'
249
- ).to_f
250
- strength_db = strength_db_float.round(1)
474
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
251
475
 
252
- current_squelch = gqrx_cmd(
476
+ squelch = gqrx_cmd(
253
477
  gqrx_sock: gqrx_sock,
254
478
  cmd: 'l SQL'
255
479
  ).to_f
@@ -269,67 +493,73 @@ module PWN
269
493
  cmd: 'l BB_GAIN'
270
494
  )
271
495
 
272
- init_freq_hash[:audio_gain_db] = audio_gain_db
273
- init_freq_hash[:squelch_set] = current_squelch
274
- init_freq_hash[:rf_gain] = rf_gain
275
- init_freq_hash[:if_gain] = if_gain
276
- init_freq_hash[:bb_gain] = bb_gain
277
- init_freq_hash[:strength_db] = strength_db
278
-
279
- # Start recording and decoding if decoder provided
280
- decoder_module = nil
281
- decoder_thread = nil
282
- record_path = nil
283
- if decoder
284
- # Resolve decoder module via case statement for extensibility
285
- case decoder
286
- when :gsm
287
- decoder_module = PWN::SDR::Decoder::GSM
288
- else
289
- raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
290
- end
496
+ rds_resp = nil
497
+ rds_resp = decode_rds(gqrx_sock: gqrx_sock) if rds
498
+
499
+ freq_obj[:audio_gain_db] = audio_gain_db
500
+ freq_obj[:demod_mode_n_passband] = demod_n_passband
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
505
+ freq_obj[:strength_db] = strength_db
506
+ freq_obj[:rds] = rds_resp
507
+ end
291
508
 
292
- # Ensure recording is off before starting
293
- record_status = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'u RECORD')
294
- gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if record_status == '1'
509
+ # Start recording and decoding if decoder provided
510
+ decoder_module = nil
511
+ decoder_thread = nil
512
+ record_path = nil
513
+ if decoder
514
+ # Resolve decoder module via case statement for extensibility
515
+ case decoder
516
+ when :gsm
517
+ decoder_module = PWN::SDR::Decoder::GSM
518
+ else
519
+ raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
520
+ end
295
521
 
296
- # Start recording
297
- gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 1', resp_ok: 'RPRT 0')
522
+ # Ensure recording is off before starting
523
+ record_status = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'u RECORD')
524
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if record_status == '1'
298
525
 
299
- # Prepare for decoder
300
- start_time = Time.now
301
- expected_filename = "gqrx_#{start_time.strftime('%Y%m%d_%H%M%S')}_#{current_freq_raw}.wav"
302
- record_path = File.join(record_dir, expected_filename)
526
+ # Start recording
527
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 1', resp_ok: 'RPRT 0')
303
528
 
304
- # Build partial gqrx_obj for decoder start
305
- gqrx_obj_partial = {
306
- gqrx_sock: gqrx_sock,
307
- record_path: record_path,
308
- frequency: current_freq,
309
- bandwidth: bandwidth,
310
- demodulator_mode: demodulator_mode
311
- }
312
-
313
- # Initialize and start decoder (module style: .start returns thread)
314
- decoder_thread = decoder_module.start(
315
- gqrx_obj: gqrx_obj_partial,
316
- **decoder_opts
317
- )
529
+ # Prepare for decoder
530
+ start_time = Time.now
531
+ expected_filename = "gqrx_#{start_time.strftime('%Y%m%d_%H%M%S')}_#{current_freq_raw}.wav"
532
+ record_path = File.join(record_dir, expected_filename)
318
533
 
319
- init_freq_hash[:decoder] = decoder
320
- init_freq_hash[:decoder_module] = decoder_module
321
- init_freq_hash[:decoder_thread] = decoder_thread
322
- init_freq_hash[:record_path] = record_path
323
- end
534
+ # Build partial gqrx_obj for decoder start
535
+ freq_obj[:record_path] = record_path
536
+
537
+ # Initialize and start decoder (module style: .start returns thread)
538
+ freq_obj[:gqrx_sock] = gqrx_sock
539
+ decoder_thread = decoder_module.start(freq_obj: freq_obj)
540
+ freq_obj.delete(:gqrx_sock)
541
+
542
+ freq_obj[:freq] = current_freq
543
+ freq_obj[:decoder] = decoder
544
+ freq_obj[:decoder_module] = decoder_module
545
+ freq_obj[:decoder_thread] = decoder_thread
546
+ freq_obj[:record_path] = record_path
324
547
  end
325
548
 
326
- init_freq_hash
549
+ freq_obj
327
550
  rescue StandardError => e
328
551
  raise e
329
552
  ensure
330
- # Ensure recording is stopped and decoder is stopped on error
331
- gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if gqrx_sock && decoder
332
- decoder_module.stop(thread: decoder_thread, gqrx_obj: init_freq_hash) if decoder_module && decoder_thread
553
+ # Ensure decoder recording stops
554
+ if decoder
555
+ gqrx_cmd(
556
+ gqrx_sock: gqrx_sock,
557
+ cmd: 'U RECORD 0',
558
+ resp_ok: 'RPRT 0'
559
+ )
560
+ decoder_module.stop(freq_obj: freq_obj)
561
+ end
562
+ disconnect(gqrx_sock: gqrx_sock) if gqrx_sock.is_a?(TCPSocket) && !keep_alive
333
563
  end
334
564
 
335
565
  # Supported Method Parameters::
@@ -338,79 +568,124 @@ module PWN
338
568
  # start_freq: 'required - Start frequency of scan range',
339
569
  # target_freq: 'required - Target frequency of scan range',
340
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)',
341
572
  # bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
342
- # overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
343
573
  # precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
344
- # lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
345
574
  # strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
346
575
  # squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
576
+ # audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
577
+ # rf_gain: 'optional - RF gain (defaults to 0.0)',
578
+ # intermediate_gain: 'optional - Intermediate gain (defaults to 32.0)',
579
+ # baseband_gain: 'optional - Baseband gain (defaults to 10.0)',
580
+ # scan_log: 'optional - Path to save detected signals log (defaults to /tmp/pwn_sdr_gqrx_scan_<start_freq>-<target_freq>_<timestamp>.json)',
347
581
  # location: 'optional - Location string to include in AI analysis (e.g., "New York, NY", 90210, GPS coords, etc.)'
348
582
  # )
349
583
 
350
584
  public_class_method def self.scan_range(opts = {})
585
+ timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
586
+ log_timestamp = Time.now.strftime('%Y-%m-%d')
587
+
351
588
  gqrx_sock = opts[:gqrx_sock]
589
+
352
590
  start_freq = opts[:start_freq]
591
+ hz_start = start_freq.to_s.cast_to_raw_hz
592
+
353
593
  target_freq = opts[:target_freq]
594
+ hz_target = target_freq.to_s.cast_to_raw_hz
595
+
354
596
  demodulator_mode = opts[:demodulator_mode]
597
+ rds = opts[:rds] ||= false
598
+
355
599
  bandwidth = opts[:bandwidth] ||= 200_000
356
- overlap_protection = opts[:overlap_protection] || false
357
600
  precision = opts[:precision] ||= 1
358
- lock_freq_duration = opts[:lock_freq_duration] ||= 0.04
359
601
  strength_lock = opts[:strength_lock] ||= -70.0
360
602
  squelch = opts[:squelch] ||= (strength_lock - 3.0)
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"
361
604
  location = opts[:location] ||= 'United States'
362
605
 
363
- timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
364
-
365
- hz_start = start_freq.to_s.raw_hz
366
- hz_target = target_freq.to_s.raw_hz
367
606
  step_hz = 10**(precision - 1)
368
607
  step = hz_start > hz_target ? -step_hz : step_hz
369
608
 
370
- # Set demodulator mode & passband once
609
+ # Set squelch once for the scan
610
+ change_squelch_resp = gqrx_cmd(
611
+ gqrx_sock: gqrx_sock,
612
+ cmd: "L SQL #{squelch}",
613
+ resp_ok: 'RPRT 0'
614
+ )
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
+
627
+ # Set demodulator mode & passband once for the scan
371
628
  mode_str = demodulator_mode.to_s.upcase
372
- passband_hz = bandwidth.to_s.raw_hz
629
+ passband_hz = bandwidth.to_s.cast_to_raw_hz
373
630
  gqrx_cmd(
374
631
  gqrx_sock: gqrx_sock,
375
632
  cmd: "M #{mode_str} #{passband_hz}",
376
633
  resp_ok: 'RPRT 0'
377
634
  )
378
635
 
379
- # Prime radio at starting frequency
380
- prev_freq_hash = init_freq(
636
+ audio_gain_db = opts[:audio_gain_db] ||= 6.0
637
+ audio_gain_db = audio_gain_db.to_f
638
+ audio_gain_db_resp = gqrx_cmd(
639
+ gqrx_sock: gqrx_sock,
640
+ cmd: "L AF #{audio_gain_db}",
641
+ resp_ok: 'RPRT 0'
642
+ )
643
+
644
+ rf_gain = opts[:rf_gain] ||= 0.0
645
+ rf_gain = rf_gain.to_f
646
+ rf_gain_resp = gqrx_cmd(
647
+ gqrx_sock: gqrx_sock,
648
+ cmd: "L RF_GAIN #{rf_gain}",
649
+ resp_ok: 'RPRT 0'
650
+ )
651
+
652
+ intermediate_gain = opts[:intermediate_gain] ||= 32.0
653
+ intermediate_gain = intermediate_gain.to_f
654
+ intermediate_resp = gqrx_cmd(
655
+ gqrx_sock: gqrx_sock,
656
+ cmd: "L IF_GAIN #{intermediate_gain}",
657
+ resp_ok: 'RPRT 0'
658
+ )
659
+
660
+ baseband_gain = opts[:baseband_gain] ||= 10.0
661
+ baseband_gain = baseband_gain.to_f
662
+ baseband_resp = gqrx_cmd(
663
+ gqrx_sock: gqrx_sock,
664
+ cmd: "L BB_GAIN #{baseband_gain}",
665
+ resp_ok: 'RPRT 0'
666
+ )
667
+
668
+ prev_freq_obj = init_freq(
381
669
  gqrx_sock: gqrx_sock,
382
- freq: start_freq,
670
+ freq: hz_start,
383
671
  demodulator_mode: demodulator_mode,
672
+ rds: rds,
384
673
  bandwidth: bandwidth,
385
674
  squelch: squelch,
386
- suppress_details: true
675
+ suppress_details: true,
676
+ keep_alive: true
387
677
  )
388
- prev_freq_hash[:lock_freq_duration] = lock_freq_duration
389
- prev_freq_hash[:strength_lock] = strength_lock
390
678
 
391
- in_signal = false
392
679
  candidate_signals = []
393
- strength_history = []
394
680
 
395
- # ──────────────────────────────────────────────────────────────
396
681
  # Adaptive peak finder – trims weakest ends after each pass
397
682
  # Converges quickly to the true center of the bell curve
398
- # ──────────────────────────────────────────────────────────────
399
683
  find_best_peak = lambda do |opts = {}|
400
- beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.raw_hz
401
- top_of_signal_hz = opts[:top_of_signal_hz].to_s.raw_hz
402
- end_of_signal_hz = top_of_signal_hz + step_hz
403
-
404
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
405
- # puts "Current Frequency: #{current_hz.pretty_hz}"
406
- puts "Signal Began: #{beg_of_signal_hz.pretty_hz}"
407
- puts "Signal Appeared to Peak at: #{top_of_signal_hz.pretty_hz}"
408
- puts "Calculated Signal End: #{end_of_signal_hz.pretty_hz}"
409
- # steps_between_beg_n_end = ((end_of_signal_hz - beg_of_signal_hz) / step_hz).abs
410
- # 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
411
686
 
412
687
  samples = []
413
- prev_best_sample = nil
688
+ prev_best_sample = {}
414
689
  consecutive_best = 0
415
690
  direction_up = true
416
691
 
@@ -433,19 +708,19 @@ module PWN
433
708
  print '>' if direction_up
434
709
  print '<' unless direction_up
435
710
  gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
436
- sleep lock_freq_duration
437
- strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
438
- strength_db = strength_db_float.round(1)
711
+ strength_db = measure_signal_strength(gqrx_sock: gqrx_sock)
439
712
  samples.push({ hz: hz, strength_db: strength_db })
440
713
 
441
- # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
442
- # 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"
443
716
  end
444
717
 
445
718
  # Compute fresh averaged_samples from all cumulative samples
446
719
  averaged_samples = []
447
720
  samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
448
- 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)
449
724
  averaged_samples.push({ hz: hz, strength_db: avg_strength })
450
725
  end
451
726
 
@@ -475,7 +750,7 @@ module PWN
475
750
  best_sample = averaged_samples.max_by { |s| s[:strength_db] }
476
751
 
477
752
  # Check for improvement
478
- if best_sample == prev_best_sample
753
+ if best_sample[:hz] == prev_best_sample[:hz]
479
754
  consecutive_best += 1
480
755
  else
481
756
  consecutive_best = 0
@@ -484,7 +759,7 @@ module PWN
484
759
  # Dup to avoid reference issues
485
760
  prev_best_sample = best_sample.dup
486
761
 
487
- 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}"
488
763
 
489
764
  # Break if no improvement in 3 consecutive passes or theres only one sample left
490
765
  break if consecutive_best.positive? || averaged_samples.size == 1
@@ -494,91 +769,175 @@ module PWN
494
769
  end
495
770
 
496
771
  # Begin scanning range
497
- 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"
498
786
 
499
787
  signals_arr = []
500
- hz_start.step(by: step, to: hz_target) do |hz|
788
+ hz = hz_start
789
+ while hz <= hz_target
501
790
  gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
502
- sleep lock_freq_duration
503
- strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
504
- strength_db = strength_db_float.round(1)
505
- prev_strength_db = strength_history.last || -Float::INFINITY
506
-
507
- if strength_db >= strength_lock && strength_db > prev_strength_db
508
- in_signal = true
509
- strength_history.push(strength_db)
510
- strength_history.shift if strength_history.size > 5
511
- current_strength = (strength_history.sum / strength_history.size).round(1)
512
-
513
- print '.'
514
- puts "#{hz.pretty_hz} => #{strength_db}"
515
-
516
- candidate = { hz: hz, freq: hz.pretty_hz, strength: current_strength }
517
- candidate_signals.push(candidate)
518
- else
519
- if in_signal
520
- beg_of_signal_hz = candidate_signals.map { |s| s[:hz] }.min
521
- # Previous max step_hz was actually the top of the signal
522
- top_of_signal_hz = candidate_signals.map { |s| s[:hz] }.max - step_hz
523
-
524
- skip_signal = false
525
- prev_frequency = prev_freq_hash[:frequency].to_s.raw_hz
526
- distance_from_prev_detected_freq_hz = (beg_of_signal_hz - prev_frequency).abs
527
- half_bandwidth = (bandwidth / 2).to_i
528
- skip_signal = true if distance_from_prev_detected_freq_hz < half_bandwidth && overlap_protection
529
- puts "Prev Dect Freq: #{prev_frequency} | 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}"
530
- next if skip_signal
531
-
532
- best_peak = find_best_peak.call(
533
- beg_of_signal_hz: beg_of_signal_hz,
534
- 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
535
849
  )
536
850
 
537
- if best_peak[:hz] && best_peak[:strength_db] > strength_lock
538
- prev_freq_hash = init_freq(
539
- gqrx_sock: gqrx_sock,
540
- freq: best_peak[:hz],
541
- demodulator_mode: demodulator_mode,
542
- bandwidth: bandwidth,
543
- squelch: squelch,
544
- suppress_details: true
545
- )
546
- prev_freq_hash[:lock_freq_duration] = lock_freq_duration
547
- prev_freq_hash[:strength_lock] = strength_lock
548
-
549
- 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."
550
- ai_analysis = PWN::AI::Introspection.reflect_on(
551
- request: prev_freq_hash.to_json,
552
- system_role_content: system_role_content,
553
- suppress_pii_warning: true
554
- )
555
- prev_freq_hash[:ai_analysis] = ai_analysis unless ai_analysis.nil?
556
- puts "\n**** Detected Signal ****"
557
- puts JSON.pretty_generate(prev_freq_hash)
558
- signals_arr.push(prev_freq_hash)
559
- end
560
- candidate_signals.clear
561
- 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
562
863
  end
563
- in_signal = false
564
- strength_history = []
864
+ candidate_signals.clear
565
865
  end
866
+ hz += step_hz
566
867
  end
567
- signals = signals_arr.sort_by { |s| s[:frequency].to_s.raw_hz }
568
- timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
569
- duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
570
- # Convert duration seconds to hours minutes seconds
571
- hours = (duration_secs / 3600).to_i
572
- minutes = ((duration_secs % 3600) / 60).to_i
573
- seconds = (duration_secs % 60).to_i
574
- duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
575
868
 
576
- {
577
- signals: signals,
869
+ log_signals(
870
+ signals_arr: signals_arr,
578
871
  timestamp_start: timestamp_start,
579
- timestamp_end: timestamp_end,
580
- duration: duration
581
- }
872
+ scan_log: scan_log
873
+ )
874
+ rescue Interrupt
875
+ puts "\nCTRL+C detected - goodbye."
876
+ rescue StandardError => e
877
+ raise e
878
+ ensure
879
+ disconnect(gqrx_sock: gqrx_sock)
880
+ end
881
+
882
+ # Supported Method Parameters::
883
+ # PWN::SDR::GQRX.analyze_scan(
884
+ # scan_resp: 'required - Scan response hash returned from #scan_range method',
885
+ # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
886
+ # port: 'optional - GQRX target port (defaults to 7356)'
887
+ # )
888
+ public_class_method def self.analyze_scan(opts = {})
889
+ scan_resp = opts[:scan_resp]
890
+ raise 'ERROR: scan_resp is required.' if scan_resp.nil? || scan_resp[:signals].nil? || scan_resp[:signals].empty?
891
+
892
+ target = opts[:target]
893
+ port = opts[:port]
894
+ gqrx_sock = connect(
895
+ target: target,
896
+ port: port
897
+ )
898
+
899
+ scan_resp[:signals].each do |signal|
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)
904
+ freq_obj = signal.merge(freq_obj)
905
+ # Redact gqrx_sock from output
906
+ freq_obj.delete(:gqrx_sock)
907
+ puts JSON.pretty_generate(freq_obj)
908
+ print 'Press [ENTER] to continue...'
909
+ gets
910
+ puts "\n" * 3
911
+ end
912
+ rescue Interrupt
913
+ puts "\nCTRL+C detected - goodbye."
914
+ rescue StandardError => e
915
+ raise e
916
+ ensure
917
+ disconnect(gqrx_sock: gqrx_sock)
918
+ end
919
+
920
+ # Supported Method Parameters::
921
+ # PWN::SDR::GQRX.analyze_log(
922
+ # scan_log: 'required - Path to signals log file',
923
+ # target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
924
+ # port: 'optional - GQRX target port (defaults to 7356)'
925
+ # )
926
+ public_class_method def self.analyze_log(opts = {})
927
+ scan_log = opts[:scan_log]
928
+ raise 'ERROR: scan_log path is required.' unless File.exist?(scan_log)
929
+
930
+ scan_resp = JSON.parse(File.read(scan_log), symbolize_names: true)
931
+ raise 'ERROR: No signals found in log.' if scan_resp[:signals].nil? || scan_resp[:signals].empty?
932
+
933
+ target = opts[:target]
934
+ port = opts[:port]
935
+
936
+ analyze_scan(
937
+ scan_resp: scan_resp,
938
+ target: target,
939
+ port: port
940
+ )
582
941
  rescue StandardError => e
583
942
  raise e
584
943
  end
@@ -590,7 +949,7 @@ module PWN
590
949
  public_class_method def self.disconnect(opts = {})
591
950
  gqrx_sock = opts[:gqrx_sock]
592
951
 
593
- PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock)
952
+ PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock) unless gqrx_sock.closed?
594
953
  rescue StandardError => e
595
954
  raise e
596
955
  end
@@ -608,25 +967,20 @@ module PWN
608
967
  public_class_method def self.help
609
968
  puts "USAGE:
610
969
  gqrx_sock = #{self}.connect(
611
- target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
970
+ target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
612
971
  port: 'optional - GQRX target port (defaults to 7356)'
613
972
  )
614
973
 
615
- gqrx_resp = #{self}.gqrx_cmd(
616
- gqrx_sock: 'required - GQRX socket object returned from #connect method',
617
- cmd: 'required - GQRX command to execute',
618
- resp_ok: 'optional - Expected response from GQRX to indicate success'
619
- )
620
-
621
- init_freq_hash = #{self}.init_freq(
974
+ freq_obj = #{self}.init_freq(
622
975
  gqrx_sock: 'required - GQRX socket object returned from #connect method',
623
976
  freq: 'required - Frequency to set',
624
977
  demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
978
+ rds: 'optional - Boolean to enable/disable RDS decoding (defaults to false)',
625
979
  bandwidth: 'optional - Bandwidth (defaults to 200_000)',
626
980
  decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
627
- record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to ~/gqrx_recordings)',
628
- decoder_opts: 'optional - Hash of additional options for the decoder',
629
- suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)'
981
+ record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to /tmp/gqrx_recordings)',
982
+ suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)',
983
+ keep_alive: 'optional - Boolean to keep GQRX connection alive after method completion (defaults to false)'
630
984
  )
631
985
 
632
986
  scan_resp = #{self}.scan_range(
@@ -635,14 +989,29 @@ module PWN
635
989
  target_freq: 'required - Target frequency',
636
990
  demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
637
991
  bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
638
- overlap_protection: 'optional - Boolean to enable/disable bandwidth overlap protection (defaults to false)',
639
992
  precision: 'optional - Precision (Defaults to 1)',
640
- lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
641
993
  strength_lock: 'optional - Strength lock (defaults to -70.0)',
642
994
  squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
995
+ audio_gain_db: 'optional - Audio gain in dB (defaults to 6.0)',
996
+ rf_gain: 'optional - RF gain (defaults to 0.0)',
997
+ intermediate_gain: 'optional - Intermediate gain (defaults to 32.0)',
998
+ baseband_gain: 'optional - Baseband gain (defaults to 10.0)',
999
+ scan_log: 'optional - Path to save detected signals log (defaults to /tmp/pwn_sdr_gqrx_scan_<start_freq>-<target_freq>_<timestamp>.json)',
643
1000
  location: 'optional - Location string to include in AI analysis (e.g., \"New York, NY\", 90210, GPS coords, etc.)'
644
1001
  )
645
1002
 
1003
+ #{self}.analyze_scan(
1004
+ scan_resp: 'required - Scan response object from #scan_range method',
1005
+ target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
1006
+ port: 'optional - GQRX target port (defaults to 7356)'
1007
+ )
1008
+
1009
+ #{self}.analyze_log(
1010
+ scan_log: 'required - Path to signals log file',
1011
+ target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
1012
+ port: 'optional - GQRX target port (defaults to 7356)'
1013
+ )
1014
+
646
1015
  #{self}.disconnect(
647
1016
  gqrx_sock: 'required - GQRX socket object returned from #connect method'
648
1017
  )