Module: PWN::SDR::Decoder::RDS

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

Overview

RDS Decoder Module for FM Radio Signals.

Two entry points:

.sample  — non-interactive structured Hash (agents / cron / tools)
.decode  — realtime JSONL/callback stream with optional ENTER stop

Both share the same GQRX RDS protocol path (U RDS, p RDS_PI / PS_NAME / RADIOTEXT). .sample is the canonical mid-layer API that Extrospection and any other automation should call.

Constant Summary collapse

DEFAULT_SETTLE_SECS =
8.0
DEFAULT_INTERVAL =
0.75
CALLSIGN_RX =
/\A[A-Z]{1,2}[A-Z0-9]{2,4}\z/
CALLSIGN_RT_RX =
/\A([A-Z]{1,2}[A-Z0-9]{2,4})\b/

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. [email protected]



163
164
165
166
167
# File 'lib/pwn/sdr/decoder/rds.rb', line 163

public_class_method def self.authors
  "AUTHOR(S):
    0day Inc. <[email protected]>
  "
end

.decode(opts = {}) ⇒ Object



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
# File 'lib/pwn/sdr/decoder/rds.rb', line 120

public_class_method def self.decode(opts = {})
  return decode_mpx(opts) if opts.fetch(:backend, :gqrx).to_sym == :redsea
  raise ArgumentError, 'backend must be :gqrx or :redsea' unless opts.fetch(:backend, :gqrx).to_sym == :gqrx

  freq_obj = opts[:freq_obj]
  raise ArgumentError, 'freq_obj: required' unless freq_obj.is_a?(Hash)

  gqrx_sock = freq_obj[:gqrx_sock]
  raise ArgumentError, 'freq_obj[:gqrx_sock] required' unless gqrx_sock
  raise 'RDS not supported by this radio backend' unless enable_rds!(sock: gqrx_sock)

  interval = [(opts[:interval] || DEFAULT_INTERVAL).to_f, 0.01].max
  first = true
  reader = proc do
    sleep interval unless first
    first = false
    poll_once(sock: gqrx_sock)
  end
  last_resp = nil
  Base.send(:run_stream, opts.merge(
                           protocol: 'RDS', reader: reader,
                           log_obj: Base.send(:strip_freq_obj, freq_obj: freq_obj)
                         )) do |snap, emit|
    next unless snap

    pi = snap[:pi].to_s.upcase
    next unless pi.match?(/\A[0-9A-F]{4}\z/) && pi != '0000'

    response = { rds_pi: pi, rds_ps_name: snap[:ps].to_s, rds_radiotext: snap[:rt].to_s }
    next if response == last_resp

    last_resp = response.dup
    emit.call(response.merge(
                protocol: 'RDS', event: 'station', capability: 'backend-rds',
                summary: "Program ID: #{pi} | Station Name: #{response[:rds_ps_name]} | Radio Txt: #{response[:rds_radiotext]}"
              ))
  end
ensure
  disable_rds!(sock: gqrx_sock) if gqrx_sock
end

.detect(opts = {}) ⇒ Object

Realtime options forwarded to Base: on_frame (Hash callback), output (writable IO), interactive (default true), duration (seconds), stop (callable), queue_size (bounded chunks), log_file (path or false). Energy detection only; does not identify or decode RDS payloads.

Supported Method Parameters

RDS.detect(freq_obj: Hash, threshold: 8.0, on_frame: Proc)



112
113
114
115
116
117
118
# File 'lib/pwn/sdr/decoder/rds.rb', line 112

public_class_method def self.detect(opts = {})
  Base.run_detector(opts.merge(
                      protocol: 'RDS',
                      note: 'Energy detection only; no protocol payload decoding.',
                      describe: proc { |_burst| { event: 'detection', capability: 'energy-detection', decoded: false } }
                    ))
end

.helpObject

Display Usage for this Module



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/pwn/sdr/decoder/rds.rb', line 171

public_class_method def self.help
  puts "USAGE:
    # Native PHY: backend: :redsea runs the actual C++/liquid-dsp decoder.
    # Requires an explicit FM-MPX file or raw mono s16le MPX IO (not speaker audio).
    # No implicit RF. Emits only complete four-block syndrome-checked groups.
    #{self}.decode(freq_obj: {}, backend: :redsea, executable: 'redsea', file: 'mpx.flac', interactive: false)
    # Default backend: :gqrx retains historical metadata snapshots, not native PHY.
    # Detect energy only (not protocol payloads); accepts Base runner controls.
    #{self}.detect(freq_obj: {}, threshold: 8.0, on_frame: nil)
    # Run sample and return its result
    #{self}.sample(
      gqrx_sock: 'required - required unless freq_obj - TCPSocket from GQRX.connect',
      freq_obj: 'required - required unless gqrx_sock - Hash from GQRX.init_freq',
      settle_secs: 'optional - seconds to sample (default 8, max 30)',
      interval: 'optional - poll interval seconds (default 0.75)',
      leave_enabled: 'optional - leave RDS decoder ON after sample (default false)',
      pi: 'required - ps_name:, radiotext:, station:',
      samples: 'required - Integer, settle_secs: Float',
      error: 'required - String?   # present when RDS backend is unavailable'
    )

    # Realtime RDS snapshots via Base (ENTER or stop/duration ends the stream)
    #{self}.decode(
      freq_obj: 'required - Hash returned from PWN::SDR::GQRX.init_freq',
      on_frame: 'optional - callback receiving each emitted Hash',
      output: 'optional - writable IO (default stdout)',
      interactive: 'optional - false disables ENTER input',
      duration: 'optional - finite seconds to run',
      stop: 'optional - callable returning true to stop',
      queue_size: 'optional - bounded pending snapshots (default 8)',
      log_file: 'optional - JSONL path or false to disable logging',
      interval: 'optional - polling interval seconds (default 0.75)'
      # backend: :redsea; executable: native binary path; file: containerized MPX
      # source: raw MPX IO; sample_rate: 128000..384000 (default 192000)
    )

    # Print the AUTHOR(S) string for this module.
    #{self}.authors
  "
  constants.sort
end

.sample(opts = {}) ⇒ Object

Supported Method Parameters

rds_hash = PWN::SDR::Decoder::RDS.sample( gqrx_sock: 'required unless freq_obj - TCPSocket from GQRX.connect', freq_obj: 'required unless gqrx_sock - Hash from GQRX.init_freq', settle_secs: 'optional - seconds to sample (default 8, max 30)', interval: 'optional - poll interval seconds (default 0.75)', leave_enabled: 'optional - leave RDS decoder ON after sample (default false)' )

Returns

{ pi:, ps_name:, radiotext:, station:, samples: Integer, settle_secs: Float, error: String? # present when RDS backend is unavailable }



40
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
# File 'lib/pwn/sdr/decoder/rds.rb', line 40

public_class_method def self.sample(opts = {})
  sock = resolve_sock(opts)
  raise ArgumentError, 'gqrx_sock: or freq_obj: with :gqrx_sock required' unless sock

  settle   = (opts[:settle_secs] || DEFAULT_SETTLE_SECS).to_f.clamp(0.5, 30.0)
  interval = [(opts[:interval] || DEFAULT_INTERVAL).to_f, 0.1].max
  leave_on = opts[:leave_enabled] ? true : false
  samples  = []

  unless enable_rds!(sock: sock)
    return {
      pi: nil,
      ps_name: nil,
      radiotext: nil,
      station: nil,
      samples: 0,
      settle_secs: settle,
      error: 'RDS not supported by this radio backend'
    }
  end

  deadline = Time.now + settle
  while Time.now < deadline
    snap = poll_once(sock: sock)
    samples << snap unless snap[:pi].empty? && snap[:ps].empty? && snap[:rt].empty?

    # Early exit once we have a non-zero PI and a non-trivial RT —
    # give one more interval for RadioText to finish filling.
    pi = snap[:pi]
    rt = snap[:rt]
    if pi =~ /\A[0-9A-F]{4}\z/ && pi != '0000' && rt.length >= 8
      sleep interval
      snap2 = poll_once(sock: sock)
      samples << snap2
      break if snap2[:rt].length >= rt.length
    end

    sleep interval
  end

  disable_rds!(sock: sock) unless leave_on

  aggregate(samples: samples, settle_secs: settle)
rescue ArgumentError
  raise
rescue StandardError => e
  disable_rds!(sock: sock) if sock && !leave_on
  {
    pi: nil,
    ps_name: nil,
    radiotext: nil,
    station: nil,
    samples: samples&.length.to_i,
    settle_secs: settle,
    error: "#{e.class}: #{e.message}"
  }
end