Module: PWN::SDR::Decoder::ADSB

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

Overview

ADS-B 1090 MHz Mode-S long-frame decoder. The UAT band selector is retained for compatibility, but UAT demodulation is not implemented.

Prefer PWN::FFI::RTLSdr,AdalmPluto,HackRF at exactly 2 Msps and run a pure-Ruby Mode-S preamble correlator + 112-bit PPM slicer over magnitude samples. Missing I/Q raises; use .detect for energy only. Offline SBS-1 CSV → .parse_line.

Defined Under Namespace

Classes: DemodIQ

Constant Summary collapse

SBS_FIELDS =
%i[
  msg_type tx_type session_id aircraft_id icao24 flight_id
  date_gen time_gen date_log time_log callsign altitude_ft
  ground_speed_kt track_deg lat lon vertical_rate_fpm squawk
  alert emergency spi on_ground
].freeze
PREAMBLE =

8 μs Mode-S preamble at 2 Msps → 16 samples: 1 0 1 0 0 0 0 1 0 1 0 0 0 0 0 0

[1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0].map(&:to_f).freeze
SAMPLES_PER_US =

@ 2 Msps

2
MODE_S_CRC_POLY =

CRC-24 (Mode-S) generator 0x1FFF409 (poly over GF(2), 24-bit)

0x1FFF409

Class Method Summary collapse

Class Method Details

.airborne_position(opts = {}) ⇒ Object

Global airborne CPR only; timestamps are seconds on the same clock. Surface CPR requires a reference and is deliberately not accepted.



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/pwn/sdr/decoder/adsb.rb', line 309

public_class_method def self.airborne_position(opts = {})
  even = opts[:even]
  odd = opts[:odd]
  times = [opts[:even_time], opts[:odd_time]]
  return nil unless even.is_a?(Hash) && odd.is_a?(Hash) && times.all? { |t| t.is_a?(Numeric) && t.finite? }
  return nil if (times[0] - times[1]).abs > 10
  return nil unless even[:icao24] == odd[:icao24] && even[:df] == odd[:df]
  return nil unless even[:cpr_format].eql?(0) && odd[:cpr_format] == 1
  return nil unless [even, odd].all? do |f|
    ((9..18).cover?(f[:type_code]) || (20..22).cover?(f[:type_code])) &&
    %i[cpr_lat cpr_lon].all? { |k| f[k].is_a?(Integer) && f[k].between?(0, 131_071) }
  end
  return nil unless (even[:type_code] < 19) == (odd[:type_code] < 19)

  yz = [even[:cpr_lat], odd[:cpr_lat]].map { |n| n / 131_072.0 }
  j = ((59 * yz[0]) - (60 * yz[1]) + 0.5).floor
  lat = [0, 1].map do |i|
    value = (360.0 / (60 - i)) * ((j % (60 - i)) + yz[i])
    value >= 270 ? value - 360 : value
  end
  return nil unless lat.all? { |v| v.abs <= 90 }

  nl = lat.map { |v| cpr_nl(latitude: v) }
  return nil unless nl[0] == nl[1]

  latest = times[0] >= times[1] ? 0 : 1
  xz = [even[:cpr_lon], odd[:cpr_lon]].map { |n| n / 131_072.0 }
  m = ((xz[0] * (nl[0] - 1)) - (xz[1] * nl[0]) + 0.5).floor
  ni = [nl[latest] - latest, 1].max
  lon = (360.0 / ni) * ((m % ni) + xz[latest])
  { lat: lat[latest], lon: lon >= 180 ? lon - 360 : lon }
end

.ais_char(opts = {}) ⇒ Object



350
351
352
353
354
# File 'lib/pwn/sdr/decoder/adsb.rb', line 350

public_class_method def self.ais_char(opts = {})
  code = opts[:code]
  table = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ##### ###############0123456789######'
  table[code] || ' '
end

.authorsObject

Author(s)

0day Inc. [email protected]



446
447
448
# File 'lib/pwn/sdr/decoder/adsb.rb', line 446

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

.crc24(opts = {}) ⇒ Object

Supported Method Parameters

crc = PWN::SDR::Decoder::ADSB.crc24(bits: Array<0|1>) CRC over all bits except the final 24 (which hold the parity).



156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/pwn/sdr/decoder/adsb.rb', line 156

public_class_method def self.crc24(opts = {})
  bits = opts[:bits] || []
  return nil if bits.length < 32

  # Mode-S CRC-24: left-shift register fed by every message bit
  # (including the 24 parity bits). A valid frame leaves residual 0.
  reg = 0
  bits.each do |b|
    reg <<= 1
    reg |= (b & 1)
    reg ^= MODE_S_CRC_POLY if reg.anybits?(0x1000000)
  end
  reg & 0xFFFFFF
end

.crc_ok?(opts = {}) ⇒ Boolean

Supported Method Parameters

ok = PWN::SDR::Decoder::ADSB.crc_ok?(bits: Array<0|1>)

Returns:

  • (Boolean)


174
175
176
177
178
179
# File 'lib/pwn/sdr/decoder/adsb.rb', line 174

public_class_method def self.crc_ok?(opts = {})
  bits = opts[:bits] || []
  return false unless [56, 112].include?(bits.length)

  crc24(bits: bits).zero?
end

.decode(opts = {}) ⇒ Object

Raises:

  • (ArgumentError)


401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
# File 'lib/pwn/sdr/decoder/adsb.rb', line 401

public_class_method def self.decode(opts = {})
  freq_obj = opts[:freq_obj]
  hz  = PWN::SDR.hz_to_i(freq: freq_obj[:freq])
  uat = hz.between?(977_000_000, 979_000_000)
  raise ArgumentError, 'ADSB UAT decoding is not implemented; use detect for energy only' if uat

  proto = 'ADSB-1090ES'
  rate  = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
  PWN::SDR::Decoder::Base.run_iq(
    **opts,
    fallback: :raise,
    freq_obj: freq_obj,
    protocol: proto,
    sample_rate: rate,
    source: opts[:source],
    file: opts[:file],
    demod: DemodIQ.new(rate: rate, reference: opts[:reference]),
    note: 'Mode-S 1 Mbit/s PPM at 2 Msps; DF17/18 CRC-validated frames only.',
    describe: proc { |b| { modulation: 'PPM', frame_len_us: 120, classification: b[:duration_ms] < 5 ? 'squitter' : 'interrogation-train' } }
  )
end

.decode_modes(opts = {}) ⇒ Object

Supported Method Parameters

h = PWN::SDR::Decoder::ADSB.decode_modes(bits: Array<0|1> of length 56 or 112)



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

public_class_method def self.decode_modes(opts = {})
  bits = opts[:bits] || []
  return nil unless bits.length == 112 && bits.all? { |b| [0, 1].include?(b) } && crc_ok?(bits: bits)
  return nil unless [17, 18].include?(DSP.bits_to_int(bits: bits[0, 5]))

  # DF (5) + CA (3) + ICAO (24) + ...
  df = PWN::SDR::Decoder::DSP.bits_to_int(bits: bits[0, 5])
  icao = format('%06X', PWN::SDR::Decoder::DSP.bits_to_int(bits: bits[8, 24]))
  out = {
    protocol: 'ADSB',
    df: df,
    icao24: icao,
    bits: bits.length,
    raw_hex: bits.each_slice(4).map { |n| PWN::SDR::Decoder::DSP.bits_to_int(bits: n).to_s(16) }.join.upcase
  }
  # DF17/18 ME field (56 bits starting at bit 32)
  if [17, 18].include?(df) && bits.length >= 88
    tc = PWN::SDR::Decoder::DSP.bits_to_int(bits: bits[32, 5])
    out[:type_code] = tc
    if tc.between?(1, 4)
      # aircraft identification — 8× 6-bit AIS chars
      cs = bits[40, 48].each_slice(6).map { |ch| ais_char(code: PWN::SDR::Decoder::DSP.bits_to_int(bits: ch)) }.join.strip
      out[:callsign] = cs
    elsif tc.between?(5, 8)
      out.merge!(surface_movement(bits: bits))
      out[:cpr_format] = bits[53]
      out[:cpr_lat] = DSP.bits_to_int(bits: bits[54, 17])
      out[:cpr_lon] = DSP.bits_to_int(bits: bits[71, 17])
    elsif tc.between?(9, 18) || tc.between?(20, 22)
      out[:altitude_ft] = modes_altitude(bits12: bits[40, 12]) if tc.between?(9, 18)
      out[:gnss_height_m] = DSP.bits_to_int(bits: bits[40, 12]) if tc.between?(20, 22)
      out[:cpr_format] = bits[53]
      out[:cpr_lat] = DSP.bits_to_int(bits: bits[54, 17])
      out[:cpr_lon] = DSP.bits_to_int(bits: bits[71, 17])
    elsif tc == 19
      out.merge!(airborne_velocity(bits: bits))
    end
  end
  bits_s = []
  bits_s << "ICAO=#{out[:icao24]}"
  bits_s << "DF=#{df}"
  bits_s << "CS=#{out[:callsign]}" if out[:callsign]
  bits_s << "ALT=#{out[:altitude_ft]}ft" if out[:altitude_ft]
  bits_s << "TC=#{out[:type_code]}" if out[:type_code]
  out[:summary] = "ADSB #{bits_s.join(' ')}"
  out
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 ADSB payloads.

Supported Method Parameters

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



393
394
395
396
397
398
399
# File 'lib/pwn/sdr/decoder/adsb.rb', line 393

public_class_method def self.detect(opts = {})
  Base.run_detector(opts.merge(
                      protocol: 'ADSB',
                      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



452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
# File 'lib/pwn/sdr/decoder/adsb.rb', line 452

public_class_method def self.help
  puts "USAGE:
    # Detect energy only (not protocol payloads); accepts Base runner controls.
    #{self}.detect(freq_obj: {}, threshold: 8.0, on_frame: nil)
    # Run crc24 and return its result
    #{self}.crc24(
      bits: 'optional - bits value consumed by #crc24 (defaults to [])'
    )

    # Run crc ok and return its result
    #{self}.crc_ok?(
      bits: 'optional - bits value consumed by #crc_ok? (defaults to [])'
    )

    # Run decode modes and return its result
    #{self}.decode_modes(
      bits: 'optional - bits value consumed by #decode_modes (defaults to [])'
    )

    # Resolve local surface CPR using a reference within 45 NM of the aircraft.
    #{self}.surface_position(frame: {}, reference: [52.0, 4.0])

    # Resolve a same-aircraft airborne even/odd CPR pair within ten seconds.
    #{self}.airborne_position(
      even: 'required - decoded even airborne CPR frame Hash',
      odd: 'required - decoded odd airborne CPR frame Hash',
      even_time: 'required - even frame capture time in seconds on the shared clock',
      odd_time: 'required - odd frame capture time in seconds on the shared clock'
    )

    # Run ais char and return its result
    #{self}.ais_char(
      code: 'optional - code value consumed by #ais_char'
    )

    # Run modes altitude and return its result
    #{self}.modes_altitude(
      bits12: 'optional - bits12 value consumed by #modes_altitude'
    )

    # Run decode and return its result
    #{self}.decode(
      reference: 'optional - [latitude, longitude] within 45 NM for local surface CPR',
      freq_obj: 'required - freq_obj 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 chunks (default 8)',
      log_file: 'optional - JSONL path or false to disable logging',
      sample_rate: 'optional - sample rate value consumed by #decode',
      source: 'optional - source value consumed by #decode',
      file: 'optional - filesystem path'
    )

    # Run parse line and return its result
    #{self}.parse_line(
      line: 'optional - line value consumed by #parse_line'
    )

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

.modes_altitude(opts = {}) ⇒ Object



356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/pwn/sdr/decoder/adsb.rb', line 356

public_class_method def self.modes_altitude(opts = {})
  bits12 = opts[:bits12]
  return nil unless bits12.is_a?(Array) && bits12.length == 12 && bits12.all? { |bit| [0, 1].include?(bit) }

  if bits12[7] == 1
    n = PWN::SDR::Decoder::DSP.bits_to_int(bits: bits12[0, 7] + bits12[8, 4])
    return (n * 25) - 1000
  end
  # Q=0: D2,D4,A1,A2,A4,B1,B2,B4 Gray-code 500-ft steps;
  # C1,C2,C4 encode the reflected five-state 100-ft sequence.
  coarse = [9, 11, 1, 3, 5, 6, 8, 10].map { |i| bits12[i] }
  fine = [0, 2, 4].map { |i| bits12[i] }
  n500, n100 = [coarse, fine].map do |gray|
    binary = 0
    gray.inject(0) do |value, bit|
      binary ^= bit
      (value << 1) | binary
    end
  end
  return nil if [0, 5, 6].include?(n100)

  n100 = 5 if n100 == 7
  n100 = 6 - n100 if n500.odd?
  (n500 * 500) + (n100 * 100) - 1300
end

.parse_line(opts = {}) ⇒ Object

Supported Method Parameters

PWN::SDR::Decoder::ADSB.parse_line(line: 'MSG,3,1,1,ABCDEF,...')



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/pwn/sdr/decoder/adsb.rb', line 426

public_class_method def self.parse_line(opts = {})
  line = opts[:line].to_s
  return nil unless line.start_with?('MSG,')

  f   = line.split(',', -1)
  out = { protocol: 'ADSB' }
  SBS_FIELDS.each_with_index { |k, i| out[k] = f[i] unless f[i].to_s.empty? }
  bits = []
  bits << "ICAO=#{out[:icao24]}" if out[:icao24]
  bits << "CS=#{out[:callsign].to_s.strip}" if out[:callsign]
  bits << "ALT=#{out[:altitude_ft]}ft" if out[:altitude_ft]
  bits << "POS=#{out[:lat]},#{out[:lon]}" if out[:lat] && out[:lon]
  bits << "GS=#{out[:ground_speed_kt]}kt" if out[:ground_speed_kt]
  bits << "SQK=#{out[:squawk]}" if out[:squawk]
  out[:summary] = "ADSB #{bits.join(' ')}".strip
  out
end

.surface_position(opts = {}) ⇒ Object

Local surface CPR: reference must be within 45 NM of the aircraft. This ambiguity constraint is caller-owned, not measurable from one message. No reference means no invented position.



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/pwn/sdr/decoder/adsb.rb', line 252

public_class_method def self.surface_position(opts = {})
  frame = opts[:frame]
  reference = opts[:reference]
  return nil unless reference.is_a?(Array) && reference.length == 2 && reference.all? { |v| v.is_a?(Numeric) && v.finite? }
  return nil unless reference[0].between?(-90, 90) && reference[1].between?(-180, 180)
  return nil unless frame.is_a?(Hash) && (5..8).cover?(frame[:type_code]) && [0, 1].include?(frame[:cpr_format])
  return nil unless %i[cpr_lat cpr_lon].all? { |key| frame[key].is_a?(Integer) && frame[key].between?(0, 131_071) }

  odd = frame[:cpr_format]
  yz = frame[:cpr_lat] / 131_072.0
  xz = frame[:cpr_lon] / 131_072.0
  dlat = 90.0 / (60 - odd)
  j = (reference[0] / dlat).floor + (0.5 + ((reference[0] % dlat) / dlat) - yz).floor
  lat = dlat * (j + yz)
  return nil unless lat.between?(-90, 90)

  dlon = 90.0 / [cpr_nl(latitude: lat) - odd, 1].max
  m = (reference[1] / dlon).floor + (0.5 + ((reference[1] % dlon) / dlon) - xz).floor
  lon = (((dlon * (m + xz)) + 180) % 360) - 180
  { lat: lat, lon: lon }
end