Module: PWN::SDR::Decoder::Flex
- Defined in:
- lib/pwn/sdr/decoder/flex.rb
Overview
Pure-Ruby FLEX™ pager decoder.
FLEX is Motorola's synchronous paging protocol running at 1600 or 3200 symbols/s in 2- or 4-level FSK. GQRX's NBFM discriminator audio (48 kHz UDP tap) is fed into a per-sample PLL symbol clock, 4-level quantised, and driven through the Sync-1 → FIW → Sync-2 → 11-block state machine. All four interleaved phases (A/B/C/D) are de-interleaved into 88 × 32-bit BCH(31,21)+parity codewords, error corrected, and walked (BIW → address → vector → message words) to recover short capcode + alphanumeric / numeric / binary payloads. Plain alpha fragments have fragment checksums and whole-message signatures, with bounded per-stream ordered reassembly. Long-address phases, secure payloads, and enhanced symbolic character modes remain unsupported. Numeric/binary message-level integrity is not implemented.
The symbol/framing implementation follows multimon-ng demod_flex.c. Offline specs cover Sync-1/FIW and a protected short-address alpha frame. These fixtures do not establish all-mode live-air parity.
No multimon-ng, no sox — 100 % Ruby.
Defined Under Namespace
Classes: Demod
Constant Summary collapse
- SYNC_MARKER =
0xA6C6AAAA- SLICE_TH =
0.667- LOCKED_RATE =
0.045- UNLOCK_RATE =
0.050- A_TABLE =
Sync-1 A-word (canonical, sym<2→1 polarity) → [symbol_rate, levels]
{ 0x870C => [1600, 2], 0xB068 => [1600, 4], 0x7B18 => [3200, 2], 0xDEA0 => [3200, 4], 0x4C7C => [3200, 4] }.freeze
- NUM_TABLE =
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '/', 'U', ' ', '-', ']', '['].freeze
- TYPE_NAME =
%w[SEC SHI TON NUM SFN ALN BIN NNM].freeze
- BCH_GEN =
BCH(31,21) syndrome/correction for FLEX word ordering: bit 0..20 data (x^30..x^10), bit 21..30 check (x^9..x^0), bit 31 parity. Verified: FIW 0xF27C46AE → syndrome 0.
0x769- BCH_TAB1 =
begin t = {} 31.times { |i| t[bch_syn(word: 1 << i)] = 1 << i } t.freeze end
- BCH_TAB2 =
begin t = {} 31.times do |i| ((i + 1)..30).each { |j| t[bch_syn(word: (1 << i) | (1 << j))] ||= (1 << i) | (1 << j) } end t.freeze end
Class Method Summary collapse
- .alpha_decode(opts = {}) ⇒ Object
-
.authors ⇒ Object
- Author(s)
0day Inc.
-
.bch_fix(opts = {}) ⇒ Object
- Supported Method Parameters
fixed, nerr = PWN::SDR::Decoder::Flex.bch_fix(word: Integer) → nerr = 0/1/2 (corrected) or -1 (uncorrectable).
-
.bch_syn(opts = {}) ⇒ Object
- Supported Method Parameters
syn = PWN::SDR::Decoder::Flex.bch_syn(word: Integer).
- .decode(opts = {}) ⇒ Object
-
.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).
-
.emit_phase(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::SDR::Decoder::Flex.emit_phase( words:, phase:, cycle:, frame:, mode:, sync_cw: ) { |msg| ... }.
-
.even_parity?(opts = {}) ⇒ Boolean
- Supported Method Parameters
ok = PWN::SDR::Decoder::Flex.even_parity?(word: Integer).
-
.help ⇒ Object
Display Usage for this Module.
-
.hex_decode(opts = {}) ⇒ Object
- Supported Method Parameters
str = PWN::SDR::Decoder::Flex.hex_decode(words: [Integer, ...]).
-
.numeric_decode(opts = {}) ⇒ Object
- Supported Method Parameters
str = PWN::SDR::Decoder::Flex.numeric_decode(words:, mw1:, mw2:).
-
.popcnt(opts = {}) ⇒ Object
- Supported Method Parameters
n = PWN::SDR::Decoder::Flex.popcnt(val: Integer).
-
.sync_check(opts = {}) ⇒ Object
- Supported Method Parameters
code, polarity = PWN::SDR::Decoder::Flex.sync_check(buf: Integer) → [16-bit A-word, 0|1] or nil.
Class Method Details
.alpha_decode(opts = {}) ⇒ Object
435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 435 public_class_method def self.alpha_decode(opts = {}) words = opts[:words] || [] mw1 = opts[:mw1].to_i mw2 = opts[:mw2].to_i return [nil, nil] unless mw1.between?(1, 87) && mw2.between?(mw1, 87) hdr = words[mw1].to_i # TI SPRA193 section 2.4.1: zero K, sum 8/8/5-bit groups, # then compare its one's complement with the ten transmitted bits. fragment = words[mw1..mw2] return [nil, nil] unless fragment && fragment.length == mw2 - mw1 + 1 && fragment.all?(Integer) sum = fragment.each_with_index.sum do |word, index| data = index.zero? ? word & ~0x3FF : word (data & 0xFF) + ((data >> 8) & 0xFF) + ((data >> 16) & 0x1F) end return [nil, nil] unless (~sum & 0x3FF) == (hdr & 0x3FF) frag = (hdr >> 11) & 0x03 cont = (hdr >> 10) & 0x01 flag = if cont == 1 then 'F' elsif frag == 3 then 'K' else 'C' end out = +'' ((mw1 + 1)..mw2).each do |i| dw = words[i].to_i [0, 7, 14].each do |sh| next if frag == 3 && i == mw1 + 1 && sh.zero? ch = (dw >> sh) & 0x7F out << ch.chr end end out = out.sub(/[\x00\x03]+\z/, '') return [nil, nil] if frag == 3 && cont.zero? && (~out.bytes.sum & 0x7F) != (words[mw1 + 1].to_i & 0x7F) [out, flag] end |
.authors ⇒ Object
- Author(s)
0day Inc. [email protected]
561 562 563 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 561 public_class_method def self. "AUTHOR(S):\n 0day Inc. <[email protected]>\n" end |
.bch_fix(opts = {}) ⇒ Object
- Supported Method Parameters
fixed, nerr = PWN::SDR::Decoder::Flex.bch_fix(word: Integer) → nerr = 0/1/2 (corrected) or -1 (uncorrectable)
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 298 public_class_method def self.bch_fix(opts = {}) w = opts[:word].to_i & 0xFFFFFFFF s = bch_syn(word: w) return [w, 0] if s.zero? && even_parity?(word: w) return [w ^ 0x80000000, 1] if s.zero? if (m = BCH_TAB1[s]) return [w ^ m, 1] if even_parity?(word: w ^ m) return [w ^ m ^ 0x80000000, 2] end m = BCH_TAB2[s] return [w ^ m, 2] if m && even_parity?(word: w ^ m) [w, -1] end |
.bch_syn(opts = {}) ⇒ Object
- Supported Method Parameters
syn = PWN::SDR::Decoder::Flex.bch_syn(word: Integer)
272 273 274 275 276 277 278 279 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 272 public_class_method def self.bch_syn(opts = {}) word = opts[:word].to_i # reverse bits 0..30 so bit0 → x^30 (FLEX on-air ordering) r = 0 31.times { |i| r |= ((word >> i) & 1) << (30 - i) } 30.downto(10) { |i| r ^= BCH_GEN << (i - 10) if (r >> i).odd? } r & 0x3FF end |
.decode(opts = {}) ⇒ Object
530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 530 public_class_method def self.decode(opts = {}) freq_obj = opts[:freq_obj] # Prefer true-air I/Q (FM-demod → native audio demod) when the # operator asks for a source/file or sets freq_obj[:iq_source]. # Otherwise keep the GQRX 48 kHz UDP audio path (run_native). want_iq = opts[:source] || opts[:file] || freq_obj[:iq_source] || freq_obj[:iq_file] if want_iq PWN::SDR::Decoder::Base.run_iq( **opts, fallback: :raise, freq_obj: freq_obj, protocol: 'FLEX', demod: Demod.new(rate: (opts[:sample_rate] || freq_obj[:iq_rate] || 240_000).to_i), sample_rate: (opts[:sample_rate] || freq_obj[:iq_rate] || 240_000).to_i, source: opts[:source], file: opts[:file], fm_demod: true, note: 'FLEX true-air: FM-demod I/Q → PLL symbol clock → 4-level slicer → full 4-phase de-interleave.' ) else PWN::SDR::Decoder::Base.run_native( **opts, freq_obj: freq_obj, protocol: 'FLEX', demod: Demod.new(rate: (opts[:rate] || 48_000).to_i) ) end 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 Flex payloads.
- Supported Method Parameters
Flex.detect(freq_obj: Hash, threshold: 8.0, on_frame: Proc)
522 523 524 525 526 527 528 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 522 public_class_method def self.detect(opts = {}) Base.run_detector(opts.merge( protocol: 'FLEX', note: 'Energy detection only; no protocol payload decoding.', describe: proc { |_burst| { event: 'detection', capability: 'energy-detection', decoded: false } } )) end |
.emit_phase(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::SDR::Decoder::Flex.emit_phase( words:, phase:, cycle:, frame:, mode:, sync_cw: ) { |msg| ... }
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 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 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 320 public_class_method def self.emit_phase(opts = {}) raw = opts[:words] || [] return unless raw.length == 88 phase = opts[:phase] cycle = opts[:cycle] frame = opts[:frame] mode = opts[:mode] || [1600, 2] # BCH-fix every word; abort phase on uncorrectable BIW. fixed = raw.map { |w| bch_fix(word: w) } words = fixed.map { |w, _| w & 0x1FFFFF } errs = fixed.count { |_, e| e.negative? } biw = words[0] return if fixed[0][1].negative? a_start = ((biw >> 8) & 0x03) + 1 v_start = (biw >> 10) & 0x3F return unless a_start < v_start && v_start < 88 (a_start...v_start).each do |i| aw = words[i] next if aw.nil? || aw.zero? || aw == 0x1FFFFF long_addr = aw < 0x008001 || aw > 0x1E0000 # Long addresses span multiple words; the short-address arithmetic # below cannot recover them. Do not publish wrapped bogus capcodes. break if long_addr capcode = (aw - 0x8000) & 0xFFFFFFFF j = v_start + (i - a_start) viw = words[j] || 0 type = (viw >> 4) & 0x7 # Secure payloads have a different layout and are not plain text. next if type.zero? mw1 = (viw >> 7) & 0x7F len = (viw >> 14) & 0x7F mw2 = mw1 + len - 1 next if fixed[i][1].negative? || !fixed[j] || fixed[j][1].negative? if [0, 3, 4, 5, 6, 7].include?(type) next unless mw1.between?(j + 1, 87) && mw2.between?(mw1, 87) next if fixed[mw1..mw2].any? { |_, errors| errors.negative? } end body, frag = case type when 0, 5 then alpha_decode(words: words, mw1: mw1, mw2: mw2) when 3, 4, 7 then [numeric_decode(words: words, mw1: mw1, mw2: mw2), nil] when 6 then [hex_decode(words: words[mw1..mw2]), nil] else [nil, nil] end next if [0, 5].include?(type) && body.nil? if type == 5 body = assemble_alpha(words: words, mw1: mw1, body: body, key: [phase, capcode, (words[mw1] >> 13) & 63], fragments: opts[:fragments]) next unless body end out = { protocol: 'FLEX', mode: "#{mode[0]}/#{mode[1]}", phase: phase, cycle: cycle, frame: frame, capcode: capcode.to_s.rjust(9, '0'), long_address: long_addr, type: TYPE_NAME[type], frag: frag, type_payload: body, bch_errors: errs }.compact cf = "#{cycle.to_s.rjust(2, '0')}.#{frame.to_s.rjust(3, '0')}" out[:summary] = "FLEX|#{mode[0]}/#{mode[1]}|#{phase}|#{cf}|#{out[:capcode]}|#{out[:type]}|#{body}"[0, 200] yield out if block_given? end end |
.even_parity?(opts = {}) ⇒ Boolean
- Supported Method Parameters
ok = PWN::SDR::Decoder::Flex.even_parity?(word: Integer)
260 261 262 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 260 public_class_method def self.even_parity?(opts = {}) popcnt(val: opts[:word].to_i & 0xFFFFFFFF).even? end |
.help ⇒ Object
Display Usage for this Module
567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 567 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 sync check and return its result #{self}.sync_check( buf: 'optional - buf value consumed by #sync_check' ) # Run popcnt and return its result #{self}.popcnt( val: 'optional - val value consumed by #popcnt' ) # Run even parity and return its result #{self}.even_parity?( word: 'optional - word value consumed by #even_parity?' ) # Run bch syn and return its result #{self}.bch_syn( word: 'optional - word value consumed by #bch_syn' ) # Run bch fix and return its result #{self}.bch_fix( word: 'optional - word value consumed by #bch_fix' ) # ) { |msg| ... } #{self}.emit_phase( words: 'optional - phase:, cycle:, frame:, mode:, sync_cw: (defaults to [])', phase: 'optional - phase value consumed by #emit_phase', cycle: 'optional - cycle value consumed by #emit_phase', frame: 'optional - frame value consumed by #emit_phase', mode: 'optional - mode value consumed by #emit_phase', fragments: 'optional - caller-owned Hash preserving ordered alpha fragments across phases' ) # Run alpha decode and return its result #{self}.alpha_decode( words: 'optional - words value consumed by #alpha_decode (defaults to [])', mw1: 'optional - mw1 value consumed by #alpha_decode', mw2: 'optional - mw2 value consumed by #alpha_decode' ) # Run numeric decode and return its result #{self}.numeric_decode( words: 'optional - words value consumed by #numeric_decode (defaults to [])', mw1: 'optional - mw1 value consumed by #numeric_decode', mw2: 'optional - mw2 value consumed by #numeric_decode' ) # Run hex decode and return its result #{self}.hex_decode( words: 'optional - words value consumed by #hex_decode (defaults to [])' ) # Run decode and return its result #{self}.decode( 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', source: 'optional - source value consumed by #decode', file: 'optional - filesystem path', sample_rate: 'optional - sample rate value consumed by #decode' ) # Print the AUTHOR(S) string for this module. #{self}.authors " constants.sort end |
.hex_decode(opts = {}) ⇒ Object
- Supported Method Parameters
str = PWN::SDR::Decoder::Flex.hex_decode(words: [Integer, ...])
506 507 508 509 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 506 public_class_method def self.hex_decode(opts = {}) words = opts[:words] || [] words.compact.map { |w| format('%06X', w & 0x1FFFFF) }.join(' ') end |
.numeric_decode(opts = {}) ⇒ Object
- Supported Method Parameters
str = PWN::SDR::Decoder::Flex.numeric_decode(words:, mw1:, mw2:)
478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 478 public_class_method def self.numeric_decode(opts = {}) words = opts[:words] || [] mw1 = opts[:mw1].to_i mw2 = opts[:mw2].to_i return nil unless mw1.between?(1, 87) && mw2.between?(mw1, 87) out = +'' # Numeric payload: 4-bit digits packed across 21-bit data words, # first word bits [20:14] are header (skip 2 digits worth). bits = [] (mw1..mw2).each do |i| dw = words[i].to_i 21.times { |b| bits << ((dw >> b) & 1) } end # skip 10-bit header (checksum+len markers) bits.shift(10) bits.each_slice(4) do |nyb| break if nyb.length < 4 v = nyb[0] | (nyb[1] << 1) | (nyb[2] << 2) | (nyb[3] << 3) out << NUM_TABLE[v] end out.strip end |
.popcnt(opts = {}) ⇒ Object
- Supported Method Parameters
n = PWN::SDR::Decoder::Flex.popcnt(val: Integer)
247 248 249 250 251 252 253 254 255 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 247 public_class_method def self.popcnt(opts = {}) val = opts[:val].to_i c = 0 while val.positive? c += val & 1 val >>= 1 end c end |
.sync_check(opts = {}) ⇒ Object
- Supported Method Parameters
code, polarity = PWN::SDR::Decoder::Flex.sync_check(buf: Integer) → [16-bit A-word, 0|1] or nil
233 234 235 236 237 238 239 240 241 242 |
# File 'lib/pwn/sdr/decoder/flex.rb', line 233 public_class_method def self.sync_check(opts = {}) buf = opts[:buf].to_i [[buf, 0], [~buf & 0xFFFFFFFFFFFFFFFF, 1]].each do |b, pol| marker = (b >> 16) & 0xFFFFFFFF ch = (b >> 48) & 0xFFFF cl = (~b) & 0xFFFF return [ch, pol] if popcnt(val: marker ^ SYNC_MARKER) < 4 && popcnt(val: ch ^ cl) < 4 end nil end |