Class: PWN::SDR::Decoder::GPS::IQTracker

Inherits:
Object
  • Object
show all
Defined in:
lib/pwn/sdr/decoder/gps.rb

Overview

Optional native GNSS-SDR receiver, with a Ruby protocol boundary. Configuration and process lifetime are kept together for auditability.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(opts) ⇒ IQTracker

rubocop:disable Metrics/ClassLength

Raises:

  • (ArgumentError)


106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/pwn/sdr/decoder/gps.rb', line 106

def initialize(opts)
  @opts = opts
  @file = File.expand_path(opts[:file].to_s)
  raise ArgumentError, 'GPS IQ requires explicit source: :file and a regular file' unless opts[:source] == :file && File.file?(@file)
  raise ArgumentError, 'GPS filename contains configuration delimiters' if @file.match?(/[\r\n;]/)

  @rate = Integer(opts.fetch(:sample_rate, 4_000_000))
  raise ArgumentError, 'GPS sample_rate must be between 2 and 25 Msps' unless @rate.between?(2_000_000, 25_000_000)

  @format = { cs16: ['ishort', 'Ishort_To_Complex', 4], cs8: ['ibyte', 'Ibyte_To_Complex', 2], cf32: ['gr_complex', 'Pass_Through', 8] }[opts.fetch(:iq_format, :cs16)]
  raise ArgumentError, 'GPS iq_format must be :cs16, :cs8 or :cf32' unless @format
  raise ArgumentError, 'GPS IQ file contains an incomplete sample or is empty' if File.empty?(@file) || (File.size(@file) % @format[2]).positive?

  @duration = Float(opts.fetch(:duration, 300))
  raise ArgumentError, 'GPS duration must be finite and positive' unless @duration.finite? && @duration.positive?
end

Class Method Details

.parse_packet(opts = {}) ⇒ Object



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/pwn/sdr/decoder/gps.rb', line 240

public_class_method def self.parse_packet(opts = {})
  packet = opts[:packet]
  return nil unless packet.bytesize <= 4096

  bytes = packet.bytes
  read_varint = lambda do
    value = 0
    10.times do |index|
      byte = bytes.shift
      raise ArgumentError unless byte

      value |= (byte & 127) << (index * 7)
      return value if byte < 128
    end
    raise ArgumentError
  end
  fields = {}
  until bytes.empty?
    tag = read_varint.call
    key = { 1 => :system, 2 => :signal, 3 => :prn, 4 => :tow_ms, 5 => :nav_message }[tag >> 3]
    case tag & 7
    when 0
      value = read_varint.call
    when 2
      length = read_varint.call
      return nil if length > bytes.length

      value = bytes.shift(length).pack('C*')
    else
      return nil
    end
    fields[key] = value if key
  end
  fields
rescue ArgumentError
  nil
end

Instance Method Details

#configuration(port) ⇒ Object



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

def configuration(port)
  {
    'GNSS-SDR.internal_fs_sps' => 2_000_000,
    'ControlThread.wait_for_flowgraph' => false,
    'SignalSource.implementation' => 'File_Signal_Source',
    'SignalSource.filename' => @file,
    'SignalSource.item_type' => @format[0],
    'SignalSource.sampling_frequency' => @rate,
    'SignalSource.samples' => 0,
    'SignalSource.repeat' => false,
    'SignalSource.enable_throttle_control' => true,
    'SignalConditioner.implementation' => 'Signal_Conditioner',
    'DataTypeAdapter.implementation' => @format[1],
    'InputFilter.implementation' => 'Pass_Through',
    'Resampler.implementation' => 'Direct_Resampler',
    'Resampler.sample_freq_in' => @rate,
    'Resampler.sample_freq_out' => 2_000_000,
    'Resampler.item_type' => 'gr_complex',
    'Channels_1C.count' => 8,
    'Channels.in_acquisition' => 1,
    'Channel.signal' => '1C',
    'Acquisition_1C.implementation' => 'GPS_L1_CA_PCPS_Acquisition',
    'Acquisition_1C.item_type' => 'gr_complex',
    'Acquisition_1C.coherent_integration_time_ms' => 1,
    'Acquisition_1C.pfa' => 0.01,
    'Acquisition_1C.doppler_max' => 10_000,
    'Acquisition_1C.doppler_step' => 250,
    'Acquisition_1C.blocking' => true,
    'Tracking_1C.implementation' => 'GPS_L1_CA_DLL_PLL_Tracking',
    'Tracking_1C.item_type' => 'gr_complex',
    'Tracking_1C.pll_bw_hz' => 40.0,
    'Tracking_1C.dll_bw_hz' => 4.0,
    'TelemetryDecoder_1C.implementation' => 'GPS_L1_CA_Telemetry_Decoder',
    'Observables.implementation' => 'Hybrid_Observables',
    'PVT.implementation' => 'RTKLIB_PVT',
    'PVT.positioning_mode' => 'Single',
    'PVT.flag_rtcm_server' => false,
    'PVT.flag_rtcm_tty_port' => false,
    'PVT.flag_nmea_tty_port' => false,
    'NavDataMonitor.enable_monitor' => true,
    'NavDataMonitor.client_addresses' => '127.0.0.1',
    'NavDataMonitor.port' => port
  }.map { |key, value| "#{key}=#{value}" }.unshift('[GNSS-SDR]').join("\n")
end

#runObject



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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/pwn/sdr/decoder/gps.rb', line 168

def run
  require 'socket'
  require 'io/wait'
  require 'tmpdir'
  require 'json'
  frames = []
  count = 0
  reason = :eof
  socket = UDPSocket.new
  socket.bind('127.0.0.1', 0)
  Dir.mktmpdir('pwn-gps-') do |directory|
    config = File.join(directory, 'receiver.conf')
    diagnostics = File.join(directory, 'receiver.log')
    File.write(config, configuration(socket.addr[1]))
    begin
      pid = Process.spawn(@opts.fetch(:gnss_sdr, 'gnss-sdr'), "--config_file=#{config}", "--log_dir=#{directory}", in: File::NULL, out: diagnostics, err: %i[child out], chdir: directory, pgroup: true)
    rescue Errno::ENOENT
      raise LoadError, 'GPS IQ tracking requires optional native gnss-sdr (tested 0.0.21); install gnss-sdr or use :lnav_bits'
    end
    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @duration
    begin
      loop do
        if @opts[:stop]&.call
          reason = :stopped
          break
        end
        if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
          reason = :duration
          break
        end
        if socket.wait_readable(0.05)
          packet = self.class.parse_packet(packet: socket.recv(4096))
          if packet && packet[:system] == 'G' && packet[:signal] == '1C'
            frame = GPS.decode_monitor(packet)
            if frame
              count += 1
              frames << frame if frames.length < 1000
              @opts[:output]&.write("#{JSON.generate(frame)}\n")
              @opts[:output]&.flush
              @opts[:on_frame]&.call(frame)
            end
          end
          next
        end
        status = Process.waitpid2(pid, Process::WNOHANG)
        next unless status

        pid = nil
        raise IOError, "gnss-sdr failed: #{File.read(diagnostics)[-4000, 4000] || File.read(diagnostics)}" unless status[1].success?

        break
      end
    ensure
      if pid
        Process.kill('TERM', -pid)
        limit = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
        until Process.waitpid(pid, Process::WNOHANG)
          if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= limit
            Process.kill('KILL', -pid)
            Process.waitpid(pid)
            break
          end
          sleep 0.02
        end
      end
    end
  end
  { protocol: 'GPS', backend: 'gnss-sdr', frames: frames, frame_count: count, reason: reason, decoded: count.positive? }
ensure
  socket&.close
end