Module: PWN::SDR::Decoder::Base

Defined in:
lib/pwn/sdr/decoder/base.rb

Overview

Shared pipeline plumbing for every PWN::SDR::Decoder::* module.

Encapsulates the pattern established by PWN::SDR::Decoder::Flex and PWN::SDR::Decoder::RDS:

1. Bind to the GQRX UDP audio stream (48 kHz s16le mono).
2. Feed it through `sox` (resample to 22 050 Hz) into an external
 demodulator (multimon-ng, rtl_433, acarsdec, dsd, ...).
3. Read decoded lines back on a background thread, hand each to a
 caller-supplied parser Proc, merge with freq_obj, JSON-log it.
4. Show a TTY::Spinner status line and exit cleanly on [ENTER].

Decoders whose external tool needs raw I/Q direct from the SDR (ADSB, GSM, LoRa, WiFi, ZigBee, ...) can instead pass direct_cmd: — Base will spawn it stand-alone (no UDP → sox bridge) and still handle the spinner / stdout parser / logging / [ENTER]-to-skip loop uniformly.

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. support@0dayinc.com



223
224
225
# File 'lib/pwn/sdr/decoder/base.rb', line 223

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <support@0dayinc.com>\n"
end

.bin_available?(opts = {}) ⇒ Boolean

Supported Method Parameters

PWN::SDR::Decoder::Base.bin_available?(bin: 'multimon-ng')

Returns:

  • (Boolean)


198
199
200
201
202
203
204
205
# File 'lib/pwn/sdr/decoder/base.rb', line 198

public_class_method def self.bin_available?(opts = {})
  bin = opts[:bin].to_s
  return false if bin.empty?

  ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
    File.executable?(File.join(dir, bin))
  end
end

.helpObject

Display Usage for this Module



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/pwn/sdr/decoder/base.rb', line 229

public_class_method def self.help
  puts "USAGE:
    #{self}.run_pipeline(
      freq_obj:      'required - freq_obj from PWN::SDR::GQRX.init_freq',
      protocol:      'required - short protocol name',
      decode_cmd:    'optional - stdin pipeline (sox-resampled 22 050 Hz s16le)',
      direct_cmd:    'optional - stand-alone cmd that owns the SDR',
      line_match:    'optional - Regexp/String/Array line filter',
      parser:        'optional - Proc { |line| Hash } structured extractor',
      required_bins: 'optional - Array of executables that must be on PATH',
      resample_hz:   'optional - sox output rate (default 22050)'
    )

    #{self}.bin_available?(bin: 'multimon-ng')
    #{self}.match_line?(line: str, matcher: Regexp|String|Array)

    #{self}.authors
  "
end

.match_line?(opts = {}) ⇒ Boolean

Supported Method Parameters

PWN::SDR::Decoder::Base.match_line?(line: str, matcher: Regexp|String|Array)

Returns:

  • (Boolean)


210
211
212
213
214
215
216
217
218
219
# File 'lib/pwn/sdr/decoder/base.rb', line 210

public_class_method def self.match_line?(opts = {})
  line    = opts[:line].to_s
  matcher = opts[:matcher]
  case matcher
  when Regexp then line.match?(matcher)
  when String then line.start_with?(matcher)
  when Array  then matcher.any? { |m| match_line?(line: line, matcher: m) }
  else true
  end
end

.run_pipeline(opts = {}) ⇒ Object

Supported Method Parameters

PWN::SDR::Decoder::Base.run_pipeline( freq_obj: 'required - freq_obj Hash from PWN::SDR::GQRX.init_freq', protocol: 'required - short name used in banner / log filename', decode_cmd: 'optional - shell pipeline reading 22 050 Hz s16le on stdin', direct_cmd: 'optional - stand-alone shell cmd (owns the SDR itself)', line_match: 'optional - Regexp/String a stdout line must match', parser: 'optional - Proc.new { |line| Hash } to structure a line', required_bins: 'optional - Array of executables that must exist on PATH', resample_hz: 'optional - sox output rate for decode_cmd (default 22050)' )



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/pwn/sdr/decoder/base.rb', line 41

public_class_method def self.run_pipeline(opts = {})
  freq_obj      = opts[:freq_obj]
  protocol      = opts[:protocol] || 'SIGNAL'
  decode_cmd    = opts[:decode_cmd]
  direct_cmd    = opts[:direct_cmd]
  line_match    = opts[:line_match]
  parser        = opts[:parser]
  required_bins = Array(opts[:required_bins])
  resample_hz   = opts[:resample_hz] || 22_050

  raise 'ERROR: :freq_obj is required' unless freq_obj.is_a?(Hash)

  gqrx_sock = freq_obj[:gqrx_sock]
  udp_ip    = freq_obj[:udp_ip]   || '127.0.0.1'
  udp_port  = freq_obj[:udp_port] || 7355
  freq_obj  = freq_obj.dup
  freq_obj.delete(:gqrx_sock)
  freq_obj.delete(:decoder_module)

  skip_freq_char = "\n"

  puts JSON.pretty_generate(freq_obj)
  puts "\n*** #{protocol} Decoder ***"
  puts 'Press [ENTER] to continue to next frequency...'

  missing = required_bins.reject { |b| bin_available?(bin: b) }
  unless missing.empty?
    puts "[!] Missing required executable(s): #{missing.join(', ')}"
    puts '    Install them and re-run, or press [ENTER] to skip.'
  end

  spinner = TTY::Spinner.new(
    '[:spinner] :status',
    format: :arrow_pulse,
    clear: true,
    hide_cursor: true
  )
  spinner_overhead = 12
  max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
  banner = "INFO: Decoding #{protocol} on udp://#{udp_ip}:#{udp_port} ..."
  banner = "INFO: Decoding #{protocol} via `#{direct_cmd.to_s.split.first}` ..." if direct_cmd
  banner = banner[0...max_title_length] if banner.length > max_title_length
  spinner.update(status: banner)
  spinner.auto_spin

  log_file = "/tmp/#{protocol.downcase.gsub(/[^a-z0-9]+/, '_')}_decoder_#{Time.now.strftime('%Y%m%d')}.log"

  udp_listener = nil
  receiver_thread = nil
  mm_stdin = mm_stdout = mm_stderr = mm_wait_thr = nil

  if direct_cmd && missing.empty?
    mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(direct_cmd)
  elsif decode_cmd && missing.empty?
    udp_listener = PWN::SDR::GQRX.listen_udp(udp_ip: udp_ip, udp_port: udp_port)

    full_cmd = 'sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - ' \
               "-t raw -e signed-integer -b 16 -r #{resample_hz} -c 1 - | #{decode_cmd}"
    mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(full_cmd)

    receiver_thread = Thread.new do
      loop do
        data, = udp_listener.recv(4096)
        next unless data.to_s.bytesize.positive?

        mm_stdin.write(data)
        begin
          mm_stdin.flush
        rescue StandardError
          nil
        end
      end
    rescue IOError, Errno::EPIPE, Errno::ECONNRESET
      nil
    end
  end

  current_title = 'Waiting for data frames...'
  decoder_thread = nil
  if mm_stdout
    decoder_thread = Thread.new do
      buffer = ''
      loop do
        chunk = mm_stdout.readpartial(4096)
        buffer = "#{buffer}#{chunk}"
        while (line = buffer.slice!(/^.*\n/))
          line = line.chomp
          next if line.empty?
          next if line_match && !match_line?(line: line, matcher: line_match)

          dec_msg = { decoded_at: Time.now.strftime('%Y-%m-%d %H:%M:%S%z'), raw: line }
          dec_msg.merge!(parser.call(line)) if parser.respond_to?(:call)
          final_msg = freq_obj.merge(dec_msg)

          spinner.stop
          puts JSON.pretty_generate(final_msg)
          spinner.auto_spin

          File.open(log_file, 'a') { |f| f.puts("#{JSON.generate(final_msg)},") }
          disp = dec_msg[:summary] || line
          current_title = disp[0...max_title_length]
        end
      rescue IOError
        break
      end
    end
  elsif gqrx_sock
    # No external decoder — fall back to live signal-strength telemetry
    decoder_thread = Thread.new do
      loop do
        lvl = PWN::SDR::GQRX.cmd(gqrx_sock: gqrx_sock, cmd: 'l STRENGTH').to_f
        current_title = "signal #{format('%+.1f', lvl)} dBFS (analog / no external demod)"
        sleep 0.3
      end
    rescue StandardError
      nil
    end
  end

  loop do
    spinner.update(status: current_title)
    next unless $stdin.wait_readable(0)

    begin
      char = $stdin.read_nonblock(1)
      next unless char == skip_freq_char

      puts "\n[!] ENTER pressed → stopping #{protocol} decoder..."
      break
    rescue IO::WaitReadable, EOFError
      nil
    end
  end

  spinner.success('Decoding stopped')
rescue StandardError => e
  spinner.error("Decoding failed: #{e.message}") if defined?(spinner) && spinner
  raise
ensure
  [receiver_thread, decoder_thread].compact.each { |t| t.kill if t.alive? }
  [mm_stdin, mm_stdout, mm_stderr].compact.each do |io|
    io.close
  rescue StandardError
    nil
  end
  begin
    mm_wait_thr&.value
  rescue StandardError
    nil
  end
  PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if udp_listener
  spinner.stop if defined?(spinner) && spinner
end