pwn 0.5.506 → 0.5.507

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.
@@ -0,0 +1,656 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module PWN
7
+ module SDR
8
+ # This plugin interacts with the remote control interface of GQRX.
9
+ module GQRX
10
+ # Monkey patches for frequency handling
11
+ String.class_eval do
12
+ def raw_hz
13
+ gsub('.', '').to_i
14
+ end
15
+ end
16
+
17
+ Integer.class_eval do
18
+ # Should always return format of X.XXX.XXX.XXX
19
+ # So 002_450_000_000 becomes 2.450.000.000
20
+ # So 2_450_000_000 becomes 2.450.000.000
21
+ # So 960_000_000 becomes 960.000.000
22
+ # 1000 should be 1.000
23
+ def pretty_hz
24
+ str_hz = to_s
25
+ # Nuke leading zeros
26
+ # E.g., 002450000000 -> 2450000000
27
+ str_hz = str_hz.sub(/^0+/, '')
28
+ # Insert dots every 3 digits from the right
29
+ str_hz.reverse.scan(/.{1,3}/).join('.').reverse
30
+ end
31
+ end
32
+
33
+ # 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
+ # Supported Method Parameters::
48
+ # gqrx_resp = PWN::SDR::GQRX.gqrx_cmd(
49
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
50
+ # cmd: 'required - GQRX command to execute',
51
+ # resp_ok: 'optional - Expected response from GQRX to indicate success'
52
+ # )
53
+
54
+ public_class_method def self.gqrx_cmd(opts = {})
55
+ gqrx_sock = opts[:gqrx_sock]
56
+ cmd = opts[:cmd]
57
+ resp_ok = opts[:resp_ok]
58
+
59
+ # Most Recent GQRX Command Set:
60
+ # https://raw.githubusercontent.com/gqrx-sdr/gqrx/master/resources/remote-control.txt
61
+ # Supported commands:
62
+ # f Get frequency [Hz]
63
+ # F <frequency> Set frequency [Hz]
64
+ # m Get demodulator mode and passband
65
+ # M <mode> [passband]
66
+ # Set demodulator mode and passband [Hz]
67
+ # Passing a '?' as the first argument instead of 'mode' will return
68
+ # a space separated list of radio backend supported modes.
69
+ # l|L ?
70
+ # Get a space separated list of settings available for reading (l) or writing (L).
71
+ # l STRENGTH
72
+ # Get signal strength [dBFS]
73
+ # l SQL
74
+ # Get squelch threshold [dBFS]
75
+ # L SQL <sql>
76
+ # Set squelch threshold to <sql> [dBFS]
77
+ # l AF
78
+ # Get audio gain [dB]
79
+ # L AF <gain>
80
+ # Set audio gain to <gain> [dB]
81
+ # l <gain_name>_GAIN
82
+ # Get the value of the gain setting with the name <gain_name>
83
+ # L <gain_name>_GAIN <value>
84
+ # Set the value of the gain setting with the name <gain_name> to <value>
85
+ # p RDS_PI
86
+ # Get the RDS PI code (in hexadecimal). Returns 0000 if not applicable.
87
+ # u RECORD
88
+ # Get status of audio recorder
89
+ # U RECORD <status>
90
+ # Set status of audio recorder to <status>
91
+ # u DSP
92
+ # Get DSP (SDR receiver) status
93
+ # U DSP <status>
94
+ # Set DSP (SDR receiver) status to <status>
95
+ # u RDS
96
+ # Get RDS decoder to <status>. Only functions in WFM mode.
97
+ # U RDS <status>
98
+ # Set RDS decoder to <status>. Only functions in WFM mode.
99
+ # q|Q
100
+ # Close connection
101
+ # AOS
102
+ # Acquisition of signal (AOS) event, start audio recording
103
+ # LOS
104
+ # Loss of signal (LOS) event, stop audio recording
105
+ # LNB_LO [frequency]
106
+ # If frequency [Hz] is specified set the LNB LO frequency used for
107
+ # display. Otherwise print the current LNB LO frequency [Hz].
108
+ # \chk_vfo
109
+ # Get VFO option status (only usable for hamlib compatibility)
110
+ # \dump_state
111
+ # Dump state (only usable for hamlib compatibility)
112
+ # \get_powerstat
113
+ # Get power status (only usable for hamlib compatibility)
114
+ # v
115
+ # Get 'VFO' (only usable for hamlib compatibility)
116
+ # V
117
+ # Set 'VFO' (only usable for hamlib compatibility)
118
+ # s
119
+ # Get 'Split' mode (only usable for hamlib compatibility)
120
+ # S
121
+ # Set 'Split' mode (only usable for hamlib compatibility)
122
+ # _
123
+ # Get version
124
+ #
125
+ # Reply:
126
+ # RPRT 0
127
+ # Command successful
128
+ # RPRT 1
129
+ # Command failed
130
+
131
+ gqrx_sock.write("#{cmd}\n")
132
+ response = []
133
+ start_time = Time.now
134
+
135
+ # Wait up to 2 seconds for initial response
136
+ if gqrx_sock.wait_readable(2.0)
137
+ response.push(gqrx_sock.readline.chomp)
138
+ # Drain any additional lines quickly
139
+ loop do
140
+ # This is the main contributing factor to this scanner being slow.
141
+ # We're trading speed for accuracy here.
142
+ # break if gqrx_sock.wait_readable(0.0625).nil? && cmd == 'l STRENGTH'
143
+ break if gqrx_sock.wait_readable(0.04).nil? && cmd == 'l STRENGTH'
144
+ break if gqrx_sock.wait_readable(0.001).nil? && cmd != 'l STRENGTH'
145
+
146
+ response.push(gqrx_sock.readline.chomp)
147
+ end
148
+ end
149
+
150
+ raise "No response for command: #{cmd}" if response.empty?
151
+
152
+ response_str = response.length == 1 ? response.first : response.join(' ')
153
+
154
+ raise "ERROR!!! Command: #{cmd} Expected Resp: #{resp_ok}, Got: #{response_str}" if resp_ok && response_str != resp_ok
155
+
156
+ # 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?
158
+
159
+ response_str
160
+ rescue RuntimeError => e
161
+ puts 'WARNING: RF Gain is not supported by the radio backend.' if e.message.include?('Command: L RF_GAIN')
162
+ puts 'WARNING: Intermediate Gain is not supported by the radio backend.' if e.message.include?('Command: L IF_GAIN')
163
+ puts 'WARNING: Baseband Gain is not supported by the radio backend.' if e.message.include?('Command: L BB_GAIN')
164
+
165
+ raise e unless e.message.include?('Command: L RF_GAIN') ||
166
+ e.message.include?('Command: L IF_GAIN') ||
167
+ e.message.include?('Command: L BB_GAIN')
168
+ rescue StandardError => e
169
+ raise e
170
+ end
171
+
172
+ # Supported Method Parameters::
173
+ # init_freq_hash = PWN::SDR::GQRX.init_freq(
174
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
175
+ # freq: 'required - Frequency to set',
176
+ # demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
177
+ # bandwidth: 'optional - Bandwidth (defaults to 200_000)',
178
+ # squelch: 'optional - Squelch level to set (Defaults to current value)',
179
+ # 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)'
183
+ # )
184
+ public_class_method def self.init_freq(opts = {})
185
+ gqrx_sock = opts[:gqrx_sock]
186
+ freq = opts[:freq]
187
+ demodulator_mode = opts[:demodulator_mode] ||= 'WFM'
188
+ bandwidth = opts[:bandwidth] ||= 200_000
189
+ squelch = opts[:squelch]
190
+ decoder = opts[:decoder]
191
+ record_dir = opts[:record_dir] ||= Dir.home
192
+ decoder_opts = opts[:decoder_opts] ||= {}
193
+ suppress_details = opts[:suppress_details] || false
194
+
195
+ raise "ERROR: record_dir '#{record_dir}' does not exist. Please create it or provide a valid path." if decoder && !Dir.exist?(record_dir)
196
+
197
+ hz = freq.to_s.raw_hz
198
+
199
+ if squelch.is_a?(Float) && squelch >= -100.0 && squelch <= 0.0
200
+ change_squelch_resp = gqrx_cmd(
201
+ gqrx_sock: gqrx_sock,
202
+ cmd: "L SQL #{squelch}",
203
+ resp_ok: 'RPRT 0'
204
+ )
205
+ end
206
+
207
+ change_freq_resp = gqrx_cmd(
208
+ 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}",
219
+ resp_ok: 'RPRT 0'
220
+ )
221
+
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
+ )
233
+
234
+ # Start recording and decoding if decoder provided
235
+ decoder_module = nil
236
+ decoder_thread = nil
237
+ record_path = nil
238
+ if decoder
239
+ # Resolve decoder module via case statement for extensibility
240
+ case decoder
241
+ when :gsm
242
+ decoder_module = PWN::SDR::Decoder::GSM
243
+ else
244
+ raise "ERROR: Unknown decoder key: #{decoder}. Supported: :gsm"
245
+ end
246
+
247
+ # Ensure recording is off before starting
248
+ record_status = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'u RECORD')
249
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if record_status == '1'
250
+
251
+ # Start recording
252
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 1', resp_ok: 'RPRT 0')
253
+
254
+ # Prepare for decoder
255
+ start_time = Time.now
256
+ expected_filename = "gqrx_#{start_time.strftime('%Y%m%d_%H%M%S')}_#{current_freq_raw}.wav"
257
+ record_path = File.join(record_dir, expected_filename)
258
+
259
+ # Build partial gqrx_obj for decoder start
260
+ gqrx_obj_partial = {
261
+ gqrx_sock: gqrx_sock,
262
+ record_path: record_path,
263
+ frequency: current_freq,
264
+ bandwidth: bandwidth,
265
+ demodulator_mode: demodulator_mode
266
+ }
267
+
268
+ # Initialize and start decoder (module style: .start returns thread)
269
+ decoder_thread = decoder_module.start(
270
+ gqrx_obj: gqrx_obj_partial,
271
+ **decoder_opts
272
+ )
273
+ end
274
+
275
+ init_freq_hash = {
276
+ demod_mode_n_passband: demod_n_passband,
277
+ frequency: current_freq,
278
+ bandwidth: bandwidth,
279
+ decoder: decoder,
280
+ decoder_module: decoder_module,
281
+ decoder_thread: decoder_thread,
282
+ record_path: record_path
283
+ }
284
+
285
+ unless suppress_details
286
+ audio_gain_db = gqrx_cmd(
287
+ gqrx_sock: gqrx_sock,
288
+ cmd: 'l AF'
289
+ ).to_f
290
+
291
+ strength_db_float = gqrx_cmd(
292
+ gqrx_sock: gqrx_sock,
293
+ cmd: 'l STRENGTH'
294
+ ).to_f
295
+ strength_db = strength_db_float.round(1)
296
+
297
+ current_squelch = gqrx_cmd(
298
+ gqrx_sock: gqrx_sock,
299
+ cmd: 'l SQL'
300
+ ).to_f
301
+
302
+ rf_gain = gqrx_cmd(
303
+ gqrx_sock: gqrx_sock,
304
+ cmd: 'l RF_GAIN'
305
+ )
306
+
307
+ if_gain = gqrx_cmd(
308
+ gqrx_sock: gqrx_sock,
309
+ cmd: 'l IF_GAIN'
310
+ )
311
+
312
+ bb_gain = gqrx_cmd(
313
+ gqrx_sock: gqrx_sock,
314
+ cmd: 'l BB_GAIN'
315
+ )
316
+
317
+ init_freq_hash = {
318
+ demod_mode_n_passband: demod_n_passband,
319
+ frequency: current_freq,
320
+ bandwidth: bandwidth,
321
+ audio_gain_db: audio_gain_db,
322
+ squelch: current_squelch,
323
+ rf_gain: rf_gain,
324
+ if_gain: if_gain,
325
+ bb_gain: bb_gain,
326
+ strength_db: strength_db,
327
+ decoder: decoder,
328
+ decoder_module: decoder_module,
329
+ decoder_thread: decoder_thread,
330
+ record_path: record_path
331
+ }
332
+ end
333
+
334
+ init_freq_hash
335
+ rescue StandardError => e
336
+ raise e
337
+ ensure
338
+ # Ensure recording is stopped and decoder is stopped on error
339
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'U RECORD 0', resp_ok: 'RPRT 0') if gqrx_sock && decoder
340
+ decoder_module.stop(thread: decoder_thread, gqrx_obj: init_freq_hash) if decoder_module && decoder_thread
341
+ end
342
+
343
+ # Supported Method Parameters::
344
+ # scan_resp = PWN::SDR::GQRX.scan_range(
345
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method',
346
+ # start_freq: 'required - Start frequency of scan range',
347
+ # target_freq: 'required - Target frequency of scan range',
348
+ # demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
349
+ # bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
350
+ # precision: 'optional - Frequency step precision (number of digits; defaults to 1)',
351
+ # lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
352
+ # strength_lock: 'optional - Strength lock in dBFS (defaults to -70.0)',
353
+ # squelch: 'optional - Squelch level in dBFS (defaults to strength_lock - 3.0)',
354
+ # location: 'optional - Location string to include in AI analysis (e.g., "New York, NY", 90210, GPS coords, etc.)'
355
+ # )
356
+
357
+ public_class_method def self.scan_range(opts = {})
358
+ gqrx_sock = opts[:gqrx_sock]
359
+ start_freq = opts[:start_freq]
360
+ target_freq = opts[:target_freq]
361
+ demodulator_mode = opts[:demodulator_mode]
362
+ bandwidth = opts[:bandwidth] ||= 200_000
363
+ precision = opts[:precision] ||= 1
364
+ lock_freq_duration = opts[:lock_freq_duration] ||= 0.04
365
+ strength_lock = opts[:strength_lock] ||= -70.0
366
+ squelch = opts[:squelch] ||= (strength_lock - 3.0)
367
+ location = opts[:location] ||= 'United States'
368
+
369
+ timestamp_start = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
370
+
371
+ hz_start = start_freq.to_s.raw_hz
372
+ hz_target = target_freq.to_s.raw_hz
373
+ # step_hz = 10**(precision - 1)
374
+ step_hz = [10**(precision - 1), (bandwidth.to_i / 4)].max
375
+ step = hz_start > hz_target ? -step_hz : step_hz
376
+
377
+ # Set demodulator mode & passband once
378
+ mode_str = demodulator_mode.to_s.upcase
379
+ passband_hz = bandwidth.to_s.raw_hz
380
+ gqrx_cmd(
381
+ gqrx_sock: gqrx_sock,
382
+ cmd: "M #{mode_str} #{passband_hz}",
383
+ resp_ok: 'RPRT 0'
384
+ )
385
+
386
+ # Prime radio at starting frequency
387
+ prev_freq_hash = init_freq(
388
+ gqrx_sock: gqrx_sock,
389
+ freq: start_freq,
390
+ demodulator_mode: demodulator_mode,
391
+ bandwidth: bandwidth,
392
+ squelch: squelch,
393
+ suppress_details: true
394
+ )
395
+ prev_freq_hash[:lock_freq_duration] = lock_freq_duration
396
+ prev_freq_hash[:strength_lock] = strength_lock
397
+
398
+ in_signal = false
399
+ candidate_signals = []
400
+ strength_history = []
401
+
402
+ # ──────────────────────────────────────────────────────────────
403
+ # Adaptive peak finder – trims weakest ends after each pass
404
+ # Converges quickly to the true center of the bell curve
405
+ # ──────────────────────────────────────────────────────────────
406
+ find_best_peak = lambda do |opts = {}|
407
+ beg_of_signal_hz = opts[:beg_of_signal_hz].to_s.raw_hz
408
+ top_of_signal_hz = opts[:top_of_signal_hz].to_s.raw_hz
409
+ end_of_signal_hz = top_of_signal_hz + step_hz
410
+
411
+ # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
412
+ # puts "Current Frequency: #{current_hz.pretty_hz}"
413
+ puts "Signal Began: #{beg_of_signal_hz.pretty_hz}"
414
+ puts "Signal Appeared to Peak at: #{top_of_signal_hz.pretty_hz}"
415
+ puts "Calculated Signal End: #{end_of_signal_hz.pretty_hz}"
416
+ # steps_between_beg_n_end = ((end_of_signal_hz - beg_of_signal_hz) / step_hz).abs
417
+ # puts steps_between_beg_n_end.inspect
418
+
419
+ samples = []
420
+ prev_best_sample = nil
421
+ consecutive_best = 0
422
+ direction_up = true
423
+
424
+ pass_count = 0
425
+ infinite_loop_safeguard = false
426
+ while true
427
+ pass_count += 1
428
+
429
+ # Safeguard against infinite loop
430
+ infinite_loop_safeguard = true if pass_count >= 100
431
+ puts 'WARNING: Infinite loop safeguard triggered in find_best_peak!' if infinite_loop_safeguard
432
+ break if infinite_loop_safeguard
433
+
434
+ direction_up = !direction_up
435
+ start_hz_direction = direction_up ? beg_of_signal_hz : end_of_signal_hz
436
+ end_hz_direction = direction_up ? end_of_signal_hz : beg_of_signal_hz
437
+ step_hz_direction = direction_up ? step_hz : -step_hz
438
+
439
+ start_hz_direction.step(by: step_hz_direction, to: end_hz_direction) do |hz|
440
+ print '>' if direction_up
441
+ print '<' unless direction_up
442
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
443
+ sleep lock_freq_duration
444
+ strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
445
+ strength_db = strength_db_float.round(1)
446
+ samples.push({ hz: hz, strength_db: strength_db })
447
+
448
+ # current_hz = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'f').to_s.raw_hz
449
+ # puts "Sampled Frequency: #{current_hz.pretty_hz} => Strength: #{strength_db} dBFS"
450
+ end
451
+
452
+ # Compute fresh averaged_samples from all cumulative samples
453
+ averaged_samples = []
454
+ samples.group_by { |s| s[:hz] }.each do |hz, grouped_samples|
455
+ avg_strength = (grouped_samples.map { |s| s[:strength_db] }.sum / grouped_samples.size).round(1)
456
+ averaged_samples.push({ hz: hz, strength_db: avg_strength })
457
+ end
458
+
459
+ # Sort by hz for trimming
460
+ averaged_samples.sort_by! { |s| s[:hz] }
461
+
462
+ # Find current best for trimming threshold
463
+ best_sample = averaged_samples.max_by { |s| s[:strength_db] }
464
+ max_strength = best_sample[:strength_db]
465
+
466
+ # trim_db_threshold should bet average difference between
467
+ # samples near peak, floor to nearest 0.1 dB
468
+ trim_db_threshold = samples.map { |s| (s[:strength_db] - max_strength).abs }.sum / samples.size
469
+ trim_db_threshold = (trim_db_threshold * 10).floor / 10.0
470
+ puts "\nPass #{pass_count}: Calculated trim_db_threshold: #{trim_db_threshold} dB"
471
+ # Adaptive trim: Remove weak ends (implements the comment about trimming weakest ends)
472
+ averaged_samples.shift while !averaged_samples.empty? && averaged_samples.first[:strength_db] < max_strength - trim_db_threshold
473
+ averaged_samples.pop while !averaged_samples.empty? && averaged_samples.last[:strength_db] < max_strength - trim_db_threshold
474
+
475
+ # Update range for next pass if trimmed
476
+ unless averaged_samples.empty?
477
+ beg_of_signal_hz = averaged_samples.first[:hz]
478
+ end_of_signal_hz = averaged_samples.last[:hz]
479
+ end
480
+
481
+ # Recalculate best_sample after trim
482
+ best_sample = averaged_samples.max_by { |s| s[:strength_db] }
483
+
484
+ # Check for improvement
485
+ if best_sample == prev_best_sample
486
+ consecutive_best += 1
487
+ else
488
+ consecutive_best = 0
489
+ end
490
+
491
+ # Dup to avoid reference issues
492
+ prev_best_sample = best_sample.dup
493
+
494
+ puts "Pass #{pass_count}: Best #{best_sample[:hz].pretty_hz} => #{best_sample[:strength_db]} dBFS, consecutive best count: #{consecutive_best}"
495
+
496
+ # Break if no improvement in 3 consecutive passes or theres only one sample left
497
+ break if consecutive_best.positive? || averaged_samples.size == 1
498
+ end
499
+
500
+ best_sample
501
+ end
502
+
503
+ # Begin scanning range
504
+ 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"
505
+
506
+ signals_arr = []
507
+ hz_start.step(by: step, to: hz_target) do |hz|
508
+ gqrx_cmd(gqrx_sock: gqrx_sock, cmd: "F #{hz}")
509
+ sleep lock_freq_duration
510
+ strength_db_float = gqrx_cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
511
+ strength_db = strength_db_float.round(1)
512
+ prev_strength_db = strength_history.last || -Float::INFINITY
513
+
514
+ if strength_db >= strength_lock && strength_db > prev_strength_db
515
+ in_signal = true
516
+ strength_history.push(strength_db)
517
+ strength_history.shift if strength_history.size > 5
518
+ current_strength = (strength_history.sum / strength_history.size).round(1)
519
+
520
+ print '.'
521
+ # puts "#{hz.pretty_hz} => #{strength_db}"
522
+
523
+ candidate = { hz: hz, freq: hz.pretty_hz, strength: current_strength }
524
+ candidate_signals.push(candidate)
525
+ else
526
+ if in_signal
527
+ beg_of_signal_hz = candidate_signals.map { |s| s[:hz] }.min
528
+ # Previous max step_hz was actually the top of the signal
529
+ top_of_signal_hz = candidate_signals.map { |s| s[:hz] }.max - step_hz
530
+
531
+ distance_from_prev_freq_hz = (beg_of_signal_hz - prev_freq_hash[:frequency].to_s.raw_hz).abs
532
+ next unless distance_from_prev_freq_hz > (bandwidth.to_i / 2)
533
+
534
+ best_peak = find_best_peak.call(
535
+ beg_of_signal_hz: beg_of_signal_hz,
536
+ top_of_signal_hz: top_of_signal_hz
537
+ )
538
+
539
+ if best_peak[:hz] && best_peak[:strength_db] > strength_lock
540
+ detailed = init_freq(
541
+ gqrx_sock: gqrx_sock,
542
+ freq: best_peak[:hz],
543
+ demodulator_mode: demodulator_mode,
544
+ bandwidth: bandwidth,
545
+ squelch: squelch,
546
+ suppress_details: true
547
+ )
548
+ detailed[:lock_freq_duration] = lock_freq_duration
549
+ detailed[:strength_lock] = strength_lock
550
+
551
+ 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."
552
+ ai_analysis = PWN::AI::Introspection.reflect_on(
553
+ request: detailed.to_json,
554
+ system_role_content: system_role_content,
555
+ suppress_pii_warning: true
556
+ )
557
+ detailed[:ai_analysis] = ai_analysis unless ai_analysis.nil?
558
+ puts "\n**** Detected Signal ****"
559
+ puts JSON.pretty_generate(detailed)
560
+ signals_arr.push(detailed)
561
+ end
562
+ candidate_signals.clear
563
+ sleep lock_freq_duration
564
+ end
565
+ in_signal = false
566
+ strength_history = []
567
+ end
568
+ end
569
+ signals = signals_arr.sort_by { |s| s[:frequency].to_s.raw_hz }
570
+ timestamp_end = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
571
+ duration_secs = Time.parse(timestamp_end) - Time.parse(timestamp_start)
572
+ # Convert duration seconds to hours minutes seconds
573
+ hours = (duration_secs / 3600).to_i
574
+ minutes = ((duration_secs % 3600) / 60).to_i
575
+ seconds = (duration_secs % 60).to_i
576
+ duration = format('%<hrs>02d:%<mins>02d:%<secs>02d', hrs: hours, mins: minutes, secs: seconds)
577
+
578
+ {
579
+ signals: signals,
580
+ timestamp_start: timestamp_start,
581
+ timestamp_end: timestamp_end,
582
+ duration: duration
583
+ }
584
+ rescue StandardError => e
585
+ raise e
586
+ end
587
+
588
+ # Supported Method Parameters::
589
+ # PWN::SDR::GQRX.disconnect(
590
+ # gqrx_sock: 'required - GQRX socket object returned from #connect method'
591
+ # )
592
+ public_class_method def self.disconnect(opts = {})
593
+ gqrx_sock = opts[:gqrx_sock]
594
+
595
+ PWN::Plugins::Sock.disconnect(sock_obj: gqrx_sock)
596
+ rescue StandardError => e
597
+ raise e
598
+ end
599
+
600
+ # Author(s):: 0day Inc. <support@0dayinc.com>
601
+
602
+ public_class_method def self.authors
603
+ "AUTHOR(S):
604
+ 0day Inc. <support@0dayinc.com>
605
+ "
606
+ end
607
+
608
+ # Display Usage for this Module
609
+
610
+ public_class_method def self.help
611
+ puts "USAGE:
612
+ gqrx_sock = #{self}.connect(
613
+ target: 'optional - GQRX target IP address (defaults to 127.0.0.1)',
614
+ port: 'optional - GQRX target port (defaults to 7356)'
615
+ )
616
+
617
+ gqrx_resp = #{self}.gqrx_cmd(
618
+ gqrx_sock: 'required - GQRX socket object returned from #connect method',
619
+ cmd: 'required - GQRX command to execute',
620
+ resp_ok: 'optional - Expected response from GQRX to indicate success'
621
+ )
622
+
623
+ init_freq_hash = #{self}.init_freq(
624
+ gqrx_sock: 'required - GQRX socket object returned from #connect method',
625
+ freq: 'required - Frequency to set',
626
+ demodulator_mode: 'optional - Demodulator mode (defaults to WFM)',
627
+ bandwidth: 'optional - Bandwidth (defaults to 200_000)',
628
+ decoder: 'optional - Decoder key (e.g., :gsm) to start live decoding (starts recording if provided)',
629
+ record_dir: 'optional - Directory where GQRX saves recordings (required if decoder provided; defaults to ~/gqrx_recordings)',
630
+ decoder_opts: 'optional - Hash of additional options for the decoder',
631
+ suppress_details: 'optional - Boolean to include extra frequency details in return hash (defaults to false)'
632
+ )
633
+
634
+ scan_resp = #{self}.scan_range(
635
+ gqrx_sock: 'required - GQRX socket object returned from #connect method',
636
+ start_freq: 'required - Starting frequency',
637
+ target_freq: 'required - Target frequency',
638
+ demodulator_mode: 'optional - Demodulator mode (e.g. WFM, AM, FM, USB, LSB, RAW, CW, RTTY / defaults to WFM)',
639
+ bandwidth: 'optional - Bandwidth in Hz (Defaults to 200_000)',
640
+ precision: 'optional - Precision (Defaults to 1)',
641
+ lock_freq_duration: 'optional - Lock frequency duration in seconds (defaults to 0.04)',
642
+ strength_lock: 'optional - Strength lock (defaults to -70.0)',
643
+ squelch: 'optional - Squelch level (defaults to strength_lock - 3.0)',
644
+ location: 'optional - Location string to include in AI analysis (e.g., \"New York, NY\", 90210, GPS coords, etc.)'
645
+ )
646
+
647
+ #{self}.disconnect(
648
+ gqrx_sock: 'required - GQRX socket object returned from #connect method'
649
+ )
650
+
651
+ #{self}.authors
652
+ "
653
+ end
654
+ end
655
+ end
656
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- module Plugins
4
+ module SDR
5
5
  # This plugin is used for interacting with an RFIDler using the
6
6
  # the screen command as a terminal emulator.
7
7
  module RFIDler
8
8
  # Supported Method Parameters::
9
- # PWN::Plugins::RFIDler.connect_via_screen(
9
+ # PWN::SDR::RFIDler.connect_via_screen(
10
10
  # block_dev: 'optional - serial block device path (defaults to /dev/ttyUSB0)'
11
11
  # )
12
12