pwn 0.5.627 → 0.5.628

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -466,6 +466,13 @@ module PWN
466
466
  public_class_method def self.mag_sq(opts = {})
467
467
  iq = opts[:iq]
468
468
  n = iq.length / 2
469
+ if native && n >= 64 && PWN::FFI.available?(mod: :Volk)
470
+ begin
471
+ return PWN::FFI::Volk.magnitude_squared(iq: iq)
472
+ rescue StandardError
473
+ # fall through
474
+ end
475
+ end
469
476
  out = Array.new(n)
470
477
  i = 0
471
478
  while i < n
@@ -567,6 +574,428 @@ module PWN
567
574
  { lag: best_lag, score: best_sc }
568
575
  end
569
576
 
577
+ CA_G2_TAPS = {
578
+ 1 => [2, 6], 2 => [3, 7], 3 => [4, 8], 4 => [5, 9], 5 => [1, 9],
579
+ 6 => [2, 10], 7 => [1, 8], 8 => [2, 9], 9 => [3, 10], 10 => [2, 3],
580
+ 11 => [3, 4], 12 => [5, 6], 13 => [6, 7], 14 => [7, 8], 15 => [8, 9],
581
+ 16 => [9, 10], 17 => [1, 4], 18 => [2, 5], 19 => [3, 6], 20 => [4, 7],
582
+ 21 => [5, 8], 22 => [6, 9], 23 => [1, 3], 24 => [4, 6], 25 => [5, 7],
583
+ 26 => [6, 8], 27 => [7, 9], 28 => [8, 10], 29 => [1, 6], 30 => [2, 7],
584
+ 31 => [3, 8], 32 => [4, 9]
585
+ }.freeze
586
+
587
+ # ── True-air I/Q chain (all Decoder::* modules) ──────────────────
588
+
589
+ # Supported Method Parameters::
590
+ # out = PWN::SDR::Decoder::DSP.resample_iq(
591
+ # iq: 'required - interleaved [I0,Q0,…] Array<Float>',
592
+ # src_rate: 'required - Hz', dst_rate: 'required - Hz'
593
+ # )
594
+
595
+ public_class_method def self.resample_iq(opts = {})
596
+ iq = opts[:iq]
597
+ src = opts[:src_rate].to_f
598
+ dst = opts[:dst_rate].to_f
599
+ return iq.dup if (src - dst).abs < 1.0 || iq.empty?
600
+
601
+ if native && PWN::FFI.available?(mod: :Liquid)
602
+ begin
603
+ return PWN::FFI::Liquid.resample_iq(iq: iq, rate: dst / src)
604
+ rescue StandardError
605
+ # fall through
606
+ end
607
+ end
608
+ n_in = iq.length / 2
609
+ ratio = src / dst
610
+ n_out = (n_in / ratio).floor
611
+ out = Array.new(n_out * 2)
612
+ i = 0
613
+ while i < n_out
614
+ pos = i * ratio
615
+ idx = pos.floor
616
+ frac = pos - idx
617
+ 2.times do |c|
618
+ a = iq[(idx * 2) + c] || 0.0
619
+ b = iq[((idx + 1) * 2) + c] || a
620
+ out[(i * 2) + c] = a + ((b - a) * frac)
621
+ end
622
+ i += 1
623
+ end
624
+ out
625
+ end
626
+
627
+ # Supported Method Parameters::
628
+ # out = PWN::SDR::Decoder::DSP.mix_iq(
629
+ # iq: 'required - interleaved I/Q', rate: 'required - Hz',
630
+ # freq: 'required - offset Hz to shift DOWN by'
631
+ # )
632
+
633
+ public_class_method def self.mix_iq(opts = {})
634
+ iq = opts[:iq]
635
+ rate = opts[:rate].to_f
636
+ freq = opts[:freq].to_f
637
+ return iq.dup if freq.abs < 1e-3 || iq.empty?
638
+
639
+ if native && PWN::FFI.available?(mod: :Liquid)
640
+ begin
641
+ return PWN::FFI::Liquid.mix_down(iq: iq, freq: TWO_PI * freq / rate)
642
+ rescue StandardError
643
+ # fall through
644
+ end
645
+ end
646
+ n = iq.length / 2
647
+ w = TWO_PI * freq / rate
648
+ out = Array.new(n * 2)
649
+ i = 0
650
+ while i < n
651
+ ph = w * i
652
+ c = Math.cos(ph)
653
+ s = Math.sin(ph)
654
+ re = iq[i * 2].to_f
655
+ im = iq[(i * 2) + 1].to_f
656
+ out[i * 2] = (re * c) + (im * s)
657
+ out[(i * 2) + 1] = (im * c) - (re * s)
658
+ i += 1
659
+ end
660
+ out
661
+ end
662
+
663
+ # Supported Method Parameters::
664
+ # bits = PWN::SDR::Decoder::DSP.gfsk_slice(
665
+ # iq: 'required - interleaved I/Q', rate:, baud:,
666
+ # bt: 'optional - Gaussian BT (default 0.35)', invert: false
667
+ # )
668
+ # GFSK/GMSK/2-FSK: prefer liquid gmskdem at integer sps, else
669
+ # fm_demod_iq → nrz_slice.
670
+
671
+ public_class_method def self.gfsk_slice(opts = {})
672
+ iq = opts[:iq]
673
+ rate = opts[:rate].to_f
674
+ baud = opts[:baud].to_f
675
+ bt = (opts[:bt] || 0.35).to_f
676
+ inv = opts[:invert]
677
+ return [] if iq.empty? || baud <= 0
678
+
679
+ sps = rate / baud
680
+ if native && PWN::FFI.available?(mod: :Liquid) && sps >= 2.0
681
+ begin
682
+ k = sps.round
683
+ riq = (sps - k).abs < 0.02 ? iq : resample_iq(iq: iq, src_rate: rate, dst_rate: baud * k)
684
+ bits = PWN::FFI::Liquid.gmsk_demod(iq: riq, sps: k, bt: bt)
685
+ return inv ? bits.map { |b| b ^ 1 } : bits
686
+ rescue StandardError
687
+ # fall through
688
+ end
689
+ end
690
+ audio = fm_demod_iq(iq: iq)
691
+ nrz_slice(samples: audio, rate: rate, baud: baud, invert: inv)
692
+ end
693
+
694
+ # Supported Method Parameters::
695
+ # dibits = PWN::SDR::Decoder::DSP.slice_4fsk(
696
+ # samples: 'required - real FM-discriminator baseband',
697
+ # rate:, baud:
698
+ # )
699
+ # → Array<0..3> per symbol (4-level decision, adaptive thresholds).
700
+ # C4FM/4-GFSK maps +3 → 01, +1 → 00, −1 → 10, −3 → 11 in P25/DMR.
701
+
702
+ public_class_method def self.slice_4fsk(opts = {})
703
+ samples = opts[:samples]
704
+ rate = opts[:rate].to_f
705
+ baud = opts[:baud].to_f
706
+ spb = rate / baud
707
+ return [] if spb < 2.0 || samples.empty?
708
+
709
+ lp = envelope_signed(samples: dc_block(samples: samples), window: [(spb / 4.0).round, 1].max)
710
+ nsym = (lp.length / spb).floor
711
+ # sample mid-symbol values, then quantise into 4 levels
712
+ vals = Array.new(nsym) { |i| lp[((i + 0.5) * spb).floor] || 0.0 }
713
+ return [] if vals.empty?
714
+
715
+ sorted = vals.sort
716
+ lo = sorted[(nsym * 0.1).floor] || vals.min
717
+ hi = sorted[(nsym * 0.9).floor] || vals.max
718
+ step = (hi - lo) / 3.0
719
+ step = 1e-9 if step.abs < 1e-9
720
+ t_lo = lo + step
721
+ t_hi = hi - step
722
+ vals.map do |v|
723
+ if v >= t_hi then 1 # +3
724
+ elsif v >= 0 then 0 # +1
725
+ elsif v >= t_lo then 2 # −1
726
+ else 3 # −3
727
+ end
728
+ end
729
+ end
730
+
731
+ # Supported Method Parameters::
732
+ # bits = PWN::SDR::Decoder::DSP.manchester_decode(
733
+ # bits: 'required - Array<0|1> at 2×data rate',
734
+ # ieee: 'optional - true = 01→1 10→0 (IEEE 802.3), else 10→1 01→0'
735
+ # )
736
+
737
+ public_class_method def self.manchester_decode(opts = {})
738
+ bits = opts[:bits]
739
+ ieee = opts[:ieee]
740
+ out = []
741
+ i = 0
742
+ while i < bits.length - 1
743
+ a = bits[i]
744
+ b = bits[i + 1]
745
+ if a == b
746
+ i += 1 # phase slip — resync on next transition
747
+ next
748
+ end
749
+ out << (ieee ? b : a)
750
+ i += 2
751
+ end
752
+ out
753
+ end
754
+
755
+ # Supported Method Parameters::
756
+ # bits = PWN::SDR::Decoder::DSP.diff_decode(bits: Array<0|1>)
757
+ # NRZ-I / DBPSK: output 1 on transition, 0 on hold.
758
+
759
+ public_class_method def self.diff_decode(opts = {})
760
+ bits = opts[:bits]
761
+ prev = bits.first || 0
762
+ out = Array.new(bits.length - 1)
763
+ i = 1
764
+ while i < bits.length
765
+ out[i - 1] = bits[i] == prev ? 0 : 1
766
+ prev = bits[i]
767
+ i += 1
768
+ end
769
+ out
770
+ end
771
+
772
+ # Supported Method Parameters::
773
+ # bytes = PWN::SDR::Decoder::DSP.bytes_from_bits(
774
+ # bits: Array<0|1>, lsb_first: false
775
+ # )
776
+
777
+ public_class_method def self.bytes_from_bits(opts = {})
778
+ bits = opts[:bits]
779
+ lsb = opts[:lsb_first]
780
+ out = []
781
+ bits.each_slice(8) do |oct|
782
+ next if oct.length < 8
783
+
784
+ v = 0
785
+ if lsb
786
+ oct.each_with_index { |b, i| v |= (b & 1) << i }
787
+ else
788
+ oct.each { |b| v = (v << 1) | (b & 1) }
789
+ end
790
+ out << v
791
+ end
792
+ out
793
+ end
794
+
795
+ # Supported Method Parameters::
796
+ # crc = PWN::SDR::Decoder::DSP.crc16(
797
+ # bytes: Array<Integer>, poly: 0x1021, init: 0xFFFF,
798
+ # refin: false, refout: false, xorout: 0x0000
799
+ # )
800
+
801
+ public_class_method def self.crc16(opts = {})
802
+ bytes = opts[:bytes]
803
+ poly = (opts[:poly] || 0x1021).to_i
804
+ crc = (opts[:init] || 0xFFFF).to_i
805
+ refin = opts[:refin]
806
+ refout = opts[:refout]
807
+ xorout = (opts[:xorout] || 0).to_i
808
+ bytes.each do |b|
809
+ b = Integer(format('%08b', b & 0xFF).reverse, 2) if refin
810
+ crc ^= (b & 0xFF) << 8
811
+ 8.times do
812
+ crc = crc.anybits?(0x8000) ? ((crc << 1) ^ poly) : (crc << 1)
813
+ crc &= 0xFFFF
814
+ end
815
+ end
816
+ crc = Integer(format('%016b', crc).reverse, 2) if refout
817
+ crc ^ xorout
818
+ end
819
+
820
+ # Supported Method Parameters::
821
+ # out = PWN::SDR::Decoder::DSP.whiten_lfsr(
822
+ # bytes: Array<Integer>, poly: Integer, init: Integer, width: 7
823
+ # )
824
+ # Galois LFSR (MSB-first). BLE: poly 0x11 (x^7+x^4+1), init (ch|0x40).
825
+
826
+ public_class_method def self.whiten_lfsr(opts = {})
827
+ bytes = opts[:bytes]
828
+ poly = opts[:poly].to_i
829
+ reg = opts[:init].to_i
830
+ w = (opts[:width] || 7).to_i
831
+ top = 1 << (w - 1)
832
+ out = Array.new(bytes.length)
833
+ bytes.each_with_index do |byte, bi|
834
+ v = byte & 0xFF
835
+ 8.times do |i|
836
+ msb = reg.anybits?(top) ? 1 : 0
837
+ reg = ((reg << 1) & ((1 << w) - 1))
838
+ reg ^= poly if msb == 1
839
+ v ^= (msb << i)
840
+ end
841
+ out[bi] = v
842
+ end
843
+ out
844
+ end
845
+
846
+ # Supported Method Parameters::
847
+ # pulses = PWN::SDR::Decoder::DSP.ook_pulses(
848
+ # iq: 'required - interleaved I/Q', rate:,
849
+ # min_us: 'optional - drop shorter pulses (default 20)'
850
+ # )
851
+ # → Array of { level: 0|1, us: Float, samples: Int } run-length list.
852
+
853
+ public_class_method def self.ook_pulses(opts = {})
854
+ iq = opts[:iq]
855
+ rate = opts[:rate].to_f
856
+ min_us = (opts[:min_us] || 20).to_f
857
+ m2 = mag_sq(iq: iq)
858
+ return [] if m2.length < 32
859
+
860
+ # Adaptive threshold: 0.5·(floor + peak) on power domain
861
+ sorted = m2.sort
862
+ floor = sorted[m2.length / 10] || m2.min
863
+ peak = sorted[(m2.length * 9) / 10] || m2.max
864
+ thr = floor + ((peak - floor) * 0.5)
865
+ us_per = 1_000_000.0 / rate
866
+ runs = []
867
+ state = m2.first >= thr ? 1 : 0
868
+ cnt = 0
869
+ m2.each do |v|
870
+ s = v >= thr ? 1 : 0
871
+ if s == state
872
+ cnt += 1
873
+ else
874
+ runs << { level: state, us: cnt * us_per, samples: cnt } if (cnt * us_per) >= min_us
875
+ state = s
876
+ cnt = 1
877
+ end
878
+ end
879
+ runs << { level: state, us: cnt * us_per, samples: cnt } if (cnt * us_per) >= min_us
880
+ runs
881
+ end
882
+
883
+ # Supported Method Parameters::
884
+ # mag = PWN::SDR::Decoder::DSP.cfft_mag(
885
+ # iq: 'required - interleaved I/Q', n: 'optional - FFT size',
886
+ # shift: 'optional - fftshift so DC is centred (default true)'
887
+ # )
888
+
889
+ public_class_method def self.cfft_mag(opts = {})
890
+ iq = opts[:iq]
891
+ n = (opts[:n] || (iq.length / 2)).to_i
892
+ sh = opts.fetch(:shift, true)
893
+ bins =
894
+ if native && PWN::FFI.available?(mod: :FFTW)
895
+ begin
896
+ PWN::FFI::FFTW.cfft(iq: iq, n: n)
897
+ rescue StandardError
898
+ dft_naive(iq: iq, n: n)
899
+ end
900
+ else
901
+ dft_naive(iq: iq, n: n)
902
+ end
903
+ mag = bins.map { |re, im| Math.sqrt((re * re) + (im * im)) }
904
+ sh ? mag.rotate(n / 2) : mag
905
+ end
906
+
907
+ # Naive O(n²) complex DFT — pure-Ruby fallback for small n.
908
+ # Supported Method Parameters::
909
+ # bins = PWN::SDR::Decoder::DSP.dft_naive(iq:, n:)
910
+
911
+ public_class_method def self.dft_naive(opts = {})
912
+ iq = opts[:iq]
913
+ n = (opts[:n] || (iq.length / 2)).to_i
914
+ n = [n, 512].min
915
+ Array.new(n) do |k|
916
+ re = 0.0
917
+ im = 0.0
918
+ j = 0
919
+ while j < n
920
+ ph = -TWO_PI * k * j / n
921
+ c = Math.cos(ph)
922
+ s = Math.sin(ph)
923
+ xr = iq[j * 2].to_f
924
+ xi = iq[(j * 2) + 1].to_f
925
+ re += (xr * c) - (xi * s)
926
+ im += (xr * s) + (xi * c)
927
+ j += 1
928
+ end
929
+ [re, im]
930
+ end
931
+ end
932
+
933
+ # Supported Method Parameters::
934
+ # zc = PWN::SDR::Decoder::DSP.zadoff_chu(root:, n: 63)
935
+ # Returns interleaved [I0,Q0,…] Array<Float>. LTE PSS uses roots 25/29/34.
936
+
937
+ public_class_method def self.zadoff_chu(opts = {})
938
+ u = opts[:root].to_i
939
+ n = (opts[:n] || 63).to_i
940
+ out = Array.new(n * 2)
941
+ n.times do |k|
942
+ ph = -Math::PI * u * k * (k + 1) / n
943
+ out[k * 2] = Math.cos(ph)
944
+ out[(k * 2) + 1] = Math.sin(ph)
945
+ end
946
+ out
947
+ end
948
+
949
+ # Supported Method Parameters::
950
+ # chips = PWN::SDR::Decoder::DSP.ca_code(prn: 1..32)
951
+ # Returns Array<Float> of ±1.0 length 1023 (GPS L1 C/A Gold code).
952
+
953
+ public_class_method def self.ca_code(opts = {})
954
+ prn = opts[:prn].to_i
955
+ taps = CA_G2_TAPS[prn]
956
+ raise "ERROR: PRN #{prn} unsupported" unless taps
957
+
958
+ g1 = Array.new(10, 1)
959
+ g2 = Array.new(10, 1)
960
+ out = Array.new(1023)
961
+ 1023.times do |i|
962
+ g2i = g2[taps[0] - 1] ^ g2[taps[1] - 1]
963
+ out[i] = (g1[9] ^ g2i) == 1 ? -1.0 : 1.0
964
+ fb1 = g1[2] ^ g1[9]
965
+ fb2 = g2[1] ^ g2[2] ^ g2[5] ^ g2[7] ^ g2[8] ^ g2[9]
966
+ g1.unshift(fb1)
967
+ g1.pop
968
+ g2.unshift(fb2)
969
+ g2.pop
970
+ end
971
+ out
972
+ end
973
+
974
+ # Supported Method Parameters::
975
+ # out = PWN::SDR::Decoder::DSP.cmul(a: [I,Q,…], b: [I,Q,…], conj_b: false)
976
+ # Element-wise complex multiply (interleaved). Used for
977
+ # correlation / dechirp: X = A · conj(B).
978
+
979
+ public_class_method def self.cmul(opts = {})
980
+ a = opts[:a]
981
+ b = opts[:b]
982
+ cj = opts[:conj_b]
983
+ n = [a.length, b.length].min / 2
984
+ out = Array.new(n * 2)
985
+ i = 0
986
+ while i < n
987
+ ar = a[i * 2].to_f
988
+ ai = a[(i * 2) + 1].to_f
989
+ br = b[i * 2].to_f
990
+ bi = b[(i * 2) + 1].to_f
991
+ bi = -bi if cj
992
+ out[i * 2] = (ar * br) - (ai * bi)
993
+ out[(i * 2) + 1] = (ar * bi) + (ai * br)
994
+ i += 1
995
+ end
996
+ out
997
+ end
998
+
570
999
  # Author(s):: 0day Inc. <support@0dayinc.com>
571
1000
 
572
1001
  public_class_method def self.authors
@@ -597,6 +1026,20 @@ module PWN
597
1026
  #{self}.bch_31_21_syndrome(word:)
598
1027
  #{self}.baudot_decode(bits:)
599
1028
  #{self}.rms_dbfs(samples:) # → Volk (≥64)
1029
+ #{self}.resample_iq(iq:, src_rate:, dst_rate:) # → Liquid crcf
1030
+ #{self}.mix_iq(iq:, rate:, freq:) # → Liquid nco
1031
+ #{self}.gfsk_slice(iq:, rate:, baud:, bt: 0.35) # → Liquid gmskdem
1032
+ #{self}.slice_4fsk(samples:, rate:, baud:) # C4FM/4-GFSK
1033
+ #{self}.manchester_decode(bits:, ieee: false)
1034
+ #{self}.diff_decode(bits:)
1035
+ #{self}.bytes_from_bits(bits:, lsb_first: false)
1036
+ #{self}.crc16(bytes:, poly: 0x1021, init: 0xFFFF)
1037
+ #{self}.whiten_lfsr(bytes:, poly:, init:, width: 7)
1038
+ #{self}.ook_pulses(iq:, rate:, min_us: 20)
1039
+ #{self}.cfft_mag(iq:, n:, shift: true) # → FFTW
1040
+ #{self}.cmul(a:, b:, conj_b: false)
1041
+ #{self}.zadoff_chu(root:, n: 63) # LTE PSS
1042
+ #{self}.ca_code(prn: 1..32) # GPS L1 C/A
600
1043
 
601
1044
  Constants: MORSE_TABLE, BAUDOT_LTRS, BAUDOT_FIGS
602
1045
  Backends: PWN::FFI.backends # { Volk: true, Liquid: true, AdalmPluto: true, … }
@@ -3,66 +3,100 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # True-air + detector-fallback decoder for GPS.
7
- # Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
8
- # via Base.run_iq; degrades to Base.run_detector with no hardware.
6
+ # GPS L1 C/A (1575.42 MHz) true-air acquisition.
7
+ #
8
+ # Parallel-code-phase search: 1 ms of I/Q resampled to 2.046 Msps
9
+ # (2 samp/chip), FFT-correlated (via PWN::FFI::FFTW.cfft) against
10
+ # each PRN 1..32 Gold code across ±5 kHz Doppler in 500 Hz steps.
11
+ # Emits {prn:, doppler_hz:, code_phase_chips:, cn0_db_hz:} for every
12
+ # satellite whose peak/next-peak ratio clears threshold — the same
13
+ # cold-start acquisition every GNSS receiver runs, no gnss-sdr binary.
9
14
  module GPS
10
- # Streaming I/Q energy/burst demod for Base.run_iq.
15
+ CHIP_RATE = 1_023_000
16
+ ACQ_RATE = 2_046_000 # 2 samp/chip → 2046-point FFT
17
+ DOPP_RANGE = 5000
18
+ DOPP_STEP = 500
19
+ ACQ_THRESH = 2.5 # peak / mean ratio
20
+
21
+ # Streaming L1 C/A acquisition demod for Base.run_iq.
11
22
  class DemodIQ
12
- def initialize(rate:, protocol:, modulation:, extra: {})
13
- @rate = rate.to_f
14
- @protocol = protocol
15
- @modulation = modulation
16
- @extra = extra
17
- @floor = nil
18
- @in_burst = false
19
- @burst_t0 = nil
20
- @peak = -200.0
21
- @burst_n = 0
22
- @threshold = (extra[:threshold] || 8.0).to_f
23
+ def initialize(rate:)
24
+ @rate = rate.to_f
25
+ @buf = []
26
+ @seen = {}
27
+ @codes = {}
23
28
  end
24
29
 
25
- def feed_iq(samples, rate: nil, &)
30
+ def feed_iq(samples, rate: nil)
26
31
  @rate = rate.to_f if rate
27
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
28
- hop = [(@rate / 1000.0).round, 1].max
29
- i = 0
30
- while i < m2.length
31
- win = m2[i, hop]
32
- break if win.nil? || win.empty?
33
-
34
- ms = win.sum / win.length
35
- lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
36
- @floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
37
- delta = lvl - @floor
38
- if delta >= @threshold
39
- unless @in_burst
40
- @in_burst = true
41
- @burst_t0 = Time.now
42
- @peak = lvl
32
+ @buf.concat(samples)
33
+ need = (@rate / 1000.0 * 2).ceil * 2 # 1 ms complex + headroom
34
+ return if @buf.length < need * 2
35
+
36
+ ms = PWN::SDR::Decoder::DSP.resample_iq(
37
+ iq: @buf.shift(need * 2), src_rate: @rate, dst_rate: ACQ_RATE
38
+ )[0, 2046 * 2]
39
+ return if ms.nil? || ms.length < 2046 * 2
40
+
41
+ hits = acquire(ms)
42
+ hits.each do |h|
43
+ key = "PRN#{h[:prn]}"
44
+ next if @seen[key] && (@seen[key] - h[:cn0_db_hz]).abs < 1.5
45
+
46
+ @seen[key] = h[:cn0_db_hz]
47
+ yield h
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def code_fft(prn)
54
+ @codes[prn] ||= begin
55
+ chips = PWN::SDR::Decoder::DSP.ca_code(prn: prn)
56
+ # upsample to 2 samp/chip, complex (Q=0)
57
+ iq = Array.new(2046 * 2, 0.0)
58
+ 2046.times { |i| iq[i * 2] = chips[i / 2] }
59
+ PWN::FFI.available?(mod: :FFTW) ? PWN::FFI::FFTW.cfft(iq: iq, n: 2046) : PWN::SDR::Decoder::DSP.dft_naive(iq: iq, n: 2046)
60
+ end
61
+ end
62
+
63
+ def acquire(ms_iq)
64
+ out = []
65
+ (1..32).each do |prn|
66
+ cf = code_fft(prn)
67
+ best = { peak: 0.0, dopp: 0, code: 0, floor: 1.0 }
68
+ (-DOPP_RANGE..DOPP_RANGE).step(DOPP_STEP) do |fd|
69
+ mixed = PWN::SDR::Decoder::DSP.mix_iq(iq: ms_iq, rate: ACQ_RATE, freq: fd)
70
+ sig_f = PWN::FFI.available?(mod: :FFTW) ? PWN::FFI::FFTW.cfft(iq: mixed, n: 2046) : PWN::SDR::Decoder::DSP.dft_naive(iq: mixed, n: 2046)
71
+ # X = FFT(sig) · conj(FFT(code)); corr = |IFFT(X)|
72
+ x_iq = Array.new(2046 * 2)
73
+ 2046.times do |k|
74
+ ar, ai = sig_f[k]
75
+ br, bi = cf[k]
76
+ x_iq[k * 2] = (ar * br) + (ai * bi)
77
+ x_iq[(k * 2) + 1] = (ai * br) - (ar * bi)
43
78
  end
44
- @peak = lvl if lvl > @peak
45
- elsif @in_burst
46
- @in_burst = false
47
- @burst_n += 1
48
- dur_ms = ((Time.now - @burst_t0) * 1000).round
49
- msg = {
50
- protocol: @protocol, event: 'burst', source: 'iq',
51
- burst_no: @burst_n, peak_dbfs: @peak.round(1),
52
- floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
53
- duration_ms: dur_ms, modulation: @modulation,
54
- sample_rate: @rate.to_i
55
- }.merge(@extra.except(:threshold))
56
- # protocol-specific enrichment
57
- msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
58
- msg[:summary] = format(
59
- '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
60
- p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
61
- )
62
- yield msg if block_given?
79
+ corr = PWN::FFI.available?(mod: :FFTW) ? PWN::FFI::FFTW.cfft(iq: x_iq, n: 2046, sign: :backward) : PWN::SDR::Decoder::DSP.dft_naive(iq: x_iq, n: 2046)
80
+ mag = corr.map { |re, im| (re * re) + (im * im) }
81
+ pk_i = mag.each_with_index.max_by(&:first).last
82
+ pk = mag[pk_i]
83
+ # exclude ±2 chips around peak for floor estimate
84
+ floor = (mag.sum - mag[[pk_i - 4, 0].max, 9].sum) / (mag.length - 9)
85
+ best = { peak: pk, floor: floor, dopp: fd, code: pk_i } if pk / floor > best[:peak] / [best[:floor], 1e-12].max
63
86
  end
64
- i += hop
87
+ ratio = best[:peak] / [best[:floor], 1e-12].max
88
+ next unless ratio >= ACQ_THRESH
89
+
90
+ cn0 = 10.0 * Math.log10(ratio * 1000.0) # 1 ms coherent
91
+ out << {
92
+ protocol: 'GPS', event: 'acquisition', modulation: 'BPSK/DSSS',
93
+ prn: prn, doppler_hz: best[:dopp],
94
+ code_phase_chips: (best[:code] / 2.0).round(1),
95
+ peak_to_floor: ratio.round(2), cn0_db_hz: cn0.round(1),
96
+ summary: "GPS PRN#{prn} acquired: Doppler=#{best[:dopp]}Hz code=#{(best[:code] / 2.0).round(1)} C/N0≈#{cn0.round(1)}dB-Hz"
97
+ }
65
98
  end
99
+ out
66
100
  end
67
101
  end
68
102
 
@@ -73,23 +107,16 @@ module PWN
73
107
 
74
108
  public_class_method def self.decode(opts = {})
75
109
  freq_obj = opts[:freq_obj]
76
-
77
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_048_000).to_i
78
- proto = 'GPS'
79
- demod = DemodIQ.new(
80
- rate: rate, protocol: proto, modulation: 'BPSK/DSSS',
81
- extra: { threshold: 3.0 }
82
- )
110
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_048_000).to_i
83
111
  PWN::SDR::Decoder::Base.run_iq(
84
112
  freq_obj: freq_obj,
85
- protocol: proto,
113
+ protocol: 'GPS-L1CA',
86
114
  sample_rate: rate,
87
115
  source: opts[:source],
88
116
  file: opts[:file],
89
- demod: demod,
90
- threshold: 3.0,
91
- note: 'BPSK/DSSS 1.023 Mcps true-air I/Q path reports composite L1 energy; PRN acquisition is layered on FFTW when available.',
92
- describe: proc { |_b| { modulation: 'BPSK/DSSS', chip_rate: 1_023_000 } }
117
+ demod: DemodIQ.new(rate: rate),
118
+ note: 'BPSK/DSSS 1.023 Mcps — I/Q→FFT parallel-code-phase acquisition (PWN::FFI::FFTW) → PRN/Doppler/C-N0.',
119
+ describe: proc { |_b| { modulation: 'BPSK/DSSS', chip_rate: CHIP_RATE } }
93
120
  )
94
121
  end
95
122
 
@@ -101,15 +128,8 @@ module PWN
101
128
  out[:lat] = ::Regexp.last_match(1) if line =~ /Lat(?:itude)?\s*=\s*(-?[\d.]+)/i
102
129
  out[:lon] = ::Regexp.last_match(1) if line =~ /Long(?:itude)?\s*=\s*(-?[\d.]+)/i
103
130
  out[:alt] = ::Regexp.last_match(1) if line =~ /Height\s*=\s*(-?[\d.]+)/i
104
- out[:utc] = ::Regexp.last_match(1) if line =~ /UTC\s*=?\s*([\d:.-]+T?[\d:.]*)/
105
131
  out[:nmea] = line if line.start_with?('$G')
106
- out[:summary] = if out[:lat]
107
- "GPS FIX #{out[:lat]},#{out[:lon]} alt=#{out[:alt]}m"
108
- elsif out[:prn]
109
- "GPS PRN#{out[:prn]} CN0=#{out[:cn0]}"
110
- else
111
- "GPS #{line[0, 100]}"
112
- end
132
+ out[:summary] = out[:lat] ? "GPS FIX #{out[:lat]},#{out[:lon]}" : "GPS PRN#{out[:prn]} CN0=#{out[:cn0]}"
113
133
  out.compact
114
134
  end
115
135
 
@@ -124,7 +144,7 @@ module PWN
124
144
  #{self}.decode(
125
145
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
126
146
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
127
- file: 'optional - .cu8/.cs16 capture'
147
+ file: 'optional - .cu8/.cs16 capture (≥2.048 Msps)'
128
148
  )
129
149
 
130
150
  #{self}.authors