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.
@@ -3,69 +3,201 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # True-air + detector-fallback decoder for LTE.
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
+ # LTE (E-UTRA) true-air PSS/SSS cell search.
7
+ #
8
+ # I/Q resampled to 1.92 Msps (128-FFT grid), time-domain PSS
9
+ # correlation against Zadoff-Chu roots {25,29,34} → N_ID_2 ∈ {0,1,2}
10
+ # + half-frame timing + coarse CFO. SSS m-sequence pair 5 symbols
11
+ # earlier → N_ID_1 ∈ 0..167 → PCI = 3·N_ID_1 + N_ID_2. All FFTs via
12
+ # PWN::FFI::FFTW; falls back to naive DFT for small N.
9
13
  module LTE
10
- # Streaming I/Q energy/burst demod for Base.run_iq.
14
+ FS_BASE = 1_920_000
15
+ NFFT = 128
16
+ CP_NORM = 9 # samples @ 1.92 Msps for symbols 1..6 (10 for symbol 0)
17
+ PSS_ROOTS = { 0 => 25, 1 => 29, 2 => 34 }.freeze
18
+
19
+ # Streaming PSS/SSS cell-search demod for Base.run_iq.
11
20
  class DemodIQ
12
- def initialize(rate:, protocol:, modulation:, extra: {})
21
+ def initialize(rate:)
13
22
  @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
+ @buf = []
24
+ @seen = {}
25
+ @pss = build_pss
23
26
  end
24
27
 
25
- def feed_iq(samples, rate: nil, &)
28
+ def feed_iq(samples, rate: nil)
26
29
  @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
43
- 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
30
+ r = PWN::SDR::Decoder::DSP.resample_iq(
31
+ iq: samples, src_rate: @rate, dst_rate: FS_BASE
32
+ )
33
+ @buf.concat(r)
34
+ # need ≥ one 5 ms half-frame @ 1.92 Msps = 9600 complex
35
+ return if @buf.length < 9600 * 2 * 2
36
+
37
+ hit = search_pss
38
+ if hit
39
+ nid1 = search_sss(hit)
40
+ pci = nid1 ? (3 * nid1) + hit[:nid2] : nil
41
+ key = pci || "N2=#{hit[:nid2]}"
42
+ unless @seen[key]
43
+ @seen[key] = true
44
+ yield(
45
+ protocol: 'LTE', event: 'cell', modulation: 'OFDMA',
46
+ nid2: hit[:nid2], nid1: nid1, pci: pci,
47
+ cfo_hz: hit[:cfo_hz], pss_peak_ratio: hit[:ratio],
48
+ timing_sample: hit[:pos],
49
+ summary: pci ? "LTE cell PCI=#{pci} (N_ID_1=#{nid1} N_ID_2=#{hit[:nid2]}) CFO=#{hit[:cfo_hz]}Hz" : "LTE PSS lock N_ID_2=#{hit[:nid2]} CFO=#{hit[:cfo_hz]}Hz (SSS pending)"
61
50
  )
62
- yield msg if block_given?
63
51
  end
64
- i += hop
65
52
  end
53
+ @buf.shift(@buf.length - (9600 * 2)) if @buf.length > 9600 * 4
54
+ end
55
+
56
+ private
57
+
58
+ # Build 3 time-domain PSS templates (128 complex samples each).
59
+ def build_pss
60
+ PSS_ROOTS.transform_values do |root|
61
+ zc = PWN::SDR::Decoder::DSP.zadoff_chu(root: root, n: 63)
62
+ # Map 62 ZC values (drop k=31) onto ±31 subcarriers of a 128-FFT.
63
+ spec = Array.new(NFFT * 2, 0.0)
64
+ 62.times do |m|
65
+ zi = m < 31 ? m : m + 1 # skip DC element
66
+ sc = m - 31
67
+ k = sc.negative? ? sc + NFFT : sc + 1 # DC left null
68
+ spec[k * 2] = zc[zi * 2]
69
+ spec[(k * 2) + 1] = zc[(zi * 2) + 1]
70
+ end
71
+ td = PWN::FFI.available?(mod: :FFTW) ? PWN::FFI::FFTW.cfft(iq: spec, n: NFFT, sign: :backward) : PWN::SDR::Decoder::DSP.dft_naive(iq: spec, n: NFFT)
72
+ td.flat_map { |re, im| [re, im] }
73
+ end
74
+ end
75
+
76
+ def search_pss
77
+ n = @buf.length / 2
78
+ best = nil
79
+ @pss.each do |nid2, tmpl|
80
+ # sliding conj-multiply-accumulate every 4th sample for speed
81
+ i = 0
82
+ while i < n - NFFT
83
+ acc_r = 0.0
84
+ acc_i = 0.0
85
+ NFFT.times do |j|
86
+ ar = @buf[(i + j) * 2]
87
+ ai = @buf[((i + j) * 2) + 1]
88
+ br = tmpl[j * 2]
89
+ bi = tmpl[(j * 2) + 1]
90
+ acc_r += (ar * br) + (ai * bi)
91
+ acc_i += (ai * br) - (ar * bi)
92
+ end
93
+ pk = (acc_r * acc_r) + (acc_i * acc_i)
94
+ best = { nid2: nid2, pos: i, pk: pk, ang: Math.atan2(acc_i, acc_r) } if best.nil? || pk > best[:pk]
95
+ i += 4
96
+ end
97
+ end
98
+ return nil unless best
99
+
100
+ # crude floor: mean |buf|² · NFFT
101
+ m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: @buf[0, NFFT * 8])
102
+ floor = (m2.sum / m2.length) * NFFT
103
+ ratio = best[:pk] / [floor, 1e-12].max
104
+ return nil unless ratio > 6.0
105
+
106
+ # coarse CFO from CP: correlate CP with tail of same OFDM symbol
107
+ pos = best[:pos]
108
+ cfo = cp_cfo(pos)
109
+ best.merge(ratio: ratio.round(1), cfo_hz: cfo)
110
+ end
111
+
112
+ def cp_cfo(pos)
113
+ return 0 if pos < CP_NORM
114
+
115
+ a = @buf[(pos - CP_NORM) * 2, CP_NORM * 2]
116
+ b = @buf[(pos + NFFT - CP_NORM) * 2, CP_NORM * 2]
117
+ r = 0.0
118
+ im = 0.0
119
+ CP_NORM.times do |j|
120
+ r += (a[j * 2] * b[j * 2]) + (a[(j * 2) + 1] * b[(j * 2) + 1])
121
+ im += (a[(j * 2) + 1] * b[j * 2]) - (a[j * 2] * b[(j * 2) + 1])
122
+ end
123
+ (Math.atan2(im, r) * FS_BASE / (2 * Math::PI * NFFT)).round
124
+ end
125
+
126
+ # SSS is one OFDM symbol before PSS. Extract 62 subcarriers,
127
+ # brute-force N_ID_1 by testing all 168 m-sequence pairs (m0,m1).
128
+ def search_sss(hit)
129
+ pos = hit[:pos] - (NFFT + CP_NORM)
130
+ return nil if pos.negative? || (pos + NFFT) * 2 > @buf.length
131
+
132
+ sym = @buf[pos * 2, NFFT * 2]
133
+ spec = PWN::SDR::Decoder::DSP.cfft_mag(iq: sym, n: NFFT, shift: false)
134
+ # extract 62 SSS subcarriers (±31, skip DC)
135
+ d = Array.new(62)
136
+ 62.times do |m|
137
+ sc = m - 31
138
+ k = sc.negative? ? sc + NFFT : sc + 1
139
+ d[m] = spec[k]
140
+ end
141
+ # Magnitude-only heuristic (BPSK): match interleaved even/odd
142
+ # sub-sequences against m-sequence energy pattern per N_ID_1.
143
+ best = nil
144
+ 168.times do |nid1|
145
+ m0, m1 = LTE.sss_indices(nid1: nid1)
146
+ s0 = LTE.mseq(shift: m0)
147
+ s1 = LTE.mseq(shift: m1)
148
+ c0 = LTE.cseq(nid2: hit[:nid2])
149
+ score = 0.0
150
+ 31.times do |n|
151
+ score += d[2 * n] * s0[n] * c0[n]
152
+ score += d[(2 * n) + 1] * s1[n] * c0[n]
153
+ end
154
+ best = { nid1: nid1, score: score.abs } if best.nil? || score.abs > best[:score]
155
+ end
156
+ best && best[:score].positive? ? best[:nid1] : nil
66
157
  end
67
158
  end
68
159
 
160
+ # SSS helper: (m0, m1) pair for a given N_ID_1 per TS 36.211 §6.11.2.
161
+ public_class_method def self.sss_indices(opts = {})
162
+ nid1 = opts[:nid1].to_i
163
+ qp = (nid1 / 30)
164
+ q = ((nid1 + (qp * (qp + 1) / 2)) / 30)
165
+ mp = nid1 + (q * (q + 1) / 2)
166
+ m0 = mp % 31
167
+ m1 = (m0 + (mp / 31) + 1) % 31
168
+ [m0, m1]
169
+ end
170
+
171
+ # Length-31 m-sequence x^5+x^2+1, cyclic-shifted by `shift`, as ±1.
172
+ public_class_method def self.mseq(opts = {})
173
+ @mseq_base ||= begin
174
+ reg = [0, 0, 0, 0, 1]
175
+ Array.new(31) do
176
+ o = reg[0]
177
+ fb = reg[0] ^ reg[3]
178
+ reg = reg[1..] + [fb]
179
+ 1 - (2 * o)
180
+ end
181
+ end
182
+ sh = opts[:shift].to_i
183
+ Array.new(31) { |n| @mseq_base[(n + sh) % 31] }
184
+ end
185
+
186
+ # Scrambling sequence c0 (x^5+x^3+1) tied to N_ID_2, ±1.
187
+ public_class_method def self.cseq(opts = {})
188
+ @cseq_base ||= begin
189
+ reg = [0, 0, 0, 0, 1]
190
+ Array.new(31) do
191
+ o = reg[0]
192
+ fb = reg[0] ^ reg[2]
193
+ reg = reg[1..] + [fb]
194
+ 1 - (2 * o)
195
+ end
196
+ end
197
+ sh = opts[:nid2].to_i
198
+ Array.new(31) { |n| @cseq_base[(n + sh) % 31] }
199
+ end
200
+
69
201
  # Supported Method Parameters::
70
202
  # PWN::SDR::Decoder::LTE.decode(
71
203
  # freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq'
@@ -73,22 +205,15 @@ module PWN
73
205
 
74
206
  public_class_method def self.decode(opts = {})
75
207
  freq_obj = opts[:freq_obj]
76
-
77
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 2_000_000).to_i
78
- proto = 'LTE'
79
- demod = DemodIQ.new(
80
- rate: rate, protocol: proto, modulation: 'OFDMA',
81
- extra: { threshold: 4.0 }
82
- )
208
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 1_920_000).to_i
83
209
  PWN::SDR::Decoder::Base.run_iq(
84
210
  freq_obj: freq_obj,
85
- protocol: proto,
211
+ protocol: 'LTE',
86
212
  sample_rate: rate,
87
213
  source: opts[:source],
88
214
  file: opts[:file],
89
- demod: demod,
90
- threshold: 4.0,
91
- note: 'OFDMA 1.4–20 MHz — true-air I/Q path reports channel occupancy; PSS/SSS cell search layered on FFTW when available.',
215
+ demod: DemodIQ.new(rate: rate),
216
+ note: 'OFDMA — I/Q→1.92 Msps→PSS ZC-correlate (FFTW)→N_ID_2+CFO→SSS m-seq→PCI.',
92
217
  describe: proc { |_b| { modulation: 'OFDMA', subcarrier_khz: 15 } }
93
218
  )
94
219
  end
@@ -99,9 +224,7 @@ module PWN
99
224
  out[:earfcn] = ::Regexp.last_match(1) if line =~ /EARFCN[:= ]+(\d+)/i
100
225
  out[:pci] = ::Regexp.last_match(1) if line =~ /(?:PCI|N_id_cell|Id)[:= ]+(\d{1,3})/i
101
226
  out[:prb] = (::Regexp.last_match(1) || ::Regexp.last_match(2)) if line =~ /(?:PRB[:= ]+(\d+)|(\d+)\s*PRB)/i
102
- out[:cp] = ::Regexp.last_match(1) if line =~ /\b(Normal|Extended)\b\s*CP/i
103
227
  out[:rsrp] = ::Regexp.last_match(1) if line =~ /(-?\d+(?:\.\d+)?)\s*dBm/
104
- out[:freq_mhz] = ::Regexp.last_match(1) if line =~ /(\d{3,4}\.\d)\s*MHz/
105
228
  out[:summary] = "LTE PCI=#{out[:pci]} EARFCN=#{out[:earfcn]} PRB=#{out[:prb]} RSRP=#{out[:rsrp]}dBm"
106
229
  out.compact
107
230
  end
@@ -117,7 +240,7 @@ module PWN
117
240
  #{self}.decode(
118
241
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
119
242
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
120
- file: 'optional - .cu8/.cs16 capture'
243
+ file: 'optional - .cu8/.cs16 capture (≥1.92 Msps)'
121
244
  )
122
245
 
123
246
  #{self}.authors
@@ -3,66 +3,95 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # True-air + detector-fallback decoder for P25.
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
+ # APCO Project 25 Phase-1 (C4FM) true-air decoder.
7
+ #
8
+ # I/Q → PWN::FFI::Liquid.freq_demod (or DSP.fm_demod_iq) resample to
9
+ # 48 kHz → 4-level slice at 4800 sym/s → dibits → hunt the 24-symbol
10
+ # Frame Sync (0x5575F5FF77FF) → recover the 64-bit NID (12-bit NAC +
11
+ # 4-bit DUID + BCH(63,16,23) parity). Emits {nac:, duid:, duid_name:}
12
+ # per frame — the same intel OP25 / DSD show — with no external binary.
9
13
  module P25
10
- # Streaming I/Q energy/burst demod for Base.run_iq.
14
+ # 24-symbol / 48-bit Frame Sync (dibit MSB-first)
15
+ FS_DIBITS = [
16
+ 1, 1, 1, 1, 3, 1, 1, 3, 3, 3, 3, 1, 1, 3, 3, 3,
17
+ 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3
18
+ ].freeze # → 0x5575F5FF77FF (see TIA-102.BAAA)
19
+ FS_DIBITS_24 = [
20
+ 1, 1, 1, 1, 3, 1, 1, 3, 3, 3, 3, 1,
21
+ 3, 3, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3
22
+ ].freeze
23
+ # Correct 24-dibit Frame Sync per TIA-102 (+3 +3 +3 +3 −3 +3 …).
24
+ # Derived from bit pattern 5575F5FF77FF, MSB-first, 2 bits/sym,
25
+ # C4FM map: 01→+3, 00→+1, 10→−1, 11→−3 → dibits {1,0,2,3}.
26
+ FRAME_SYNC = 0x5575F5FF77FF
27
+ FS_BITS = Array.new(48) { |i| (FRAME_SYNC >> (47 - i)) & 1 }.freeze
28
+ FS_SYMS = FS_BITS.each_slice(2).map { |a, b| (a << 1) | b }.freeze
29
+
30
+ DUID_NAME = {
31
+ 0x0 => 'HDU', 0x3 => 'TDU', 0x5 => 'LDU1', 0x7 => 'TSBK',
32
+ 0xA => 'LDU2', 0xC => 'PDU', 0xF => 'TDULC'
33
+ }.freeze
34
+
35
+ # Streaming C4FM demod for Base.run_iq — I/Q → NAC/DUID frames.
11
36
  class DemodIQ
12
- def initialize(rate:, protocol:, modulation:, extra: {})
37
+ AUDIO_RATE = 48_000
38
+
39
+ def initialize(rate:)
13
40
  @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
41
+ @dibits = []
42
+ @seen_fs = 0
23
43
  end
24
44
 
25
45
  def feed_iq(samples, rate: nil, &)
26
46
  @rate = rate.to_f if rate
27
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
28
- hop = [(@rate / 1000.0).round, 1].max
47
+ audio = PWN::SDR::Decoder::DSP.fm_demod_iq(iq: samples)
48
+ audio = PWN::SDR::Decoder::DSP.resample(
49
+ samples: audio, src_rate: @rate, dst_rate: AUDIO_RATE
50
+ )
51
+ syms = PWN::SDR::Decoder::DSP.slice_4fsk(
52
+ samples: audio, rate: AUDIO_RATE, baud: 4800
53
+ )
54
+ @dibits.concat(syms)
55
+ scan(&) if block_given?
56
+ @dibits.shift(@dibits.length - 4096) if @dibits.length > 8192
57
+ end
58
+
59
+ private
60
+
61
+ def scan
62
+ fs = P25::FS_SYMS
29
63
  i = 0
30
- while i < m2.length
31
- win = m2[i, hop]
32
- break if win.nil? || win.empty?
64
+ while i <= @dibits.length - (24 + 32)
65
+ # Allow up to 3 symbol errors on FS
66
+ err = 0
67
+ j = 0
68
+ while j < 24
69
+ err += 1 if @dibits[i + j] != fs[j]
70
+ break if err > 3
33
71
 
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
43
- 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
72
+ j += 1
73
+ end
74
+ if err <= 3
75
+ nid_syms = @dibits[i + 24, 32] # 32 dibits = 64 bits
76
+ nid_bits = nid_syms.flat_map { |d| [(d >> 1) & 1, d & 1] }
77
+ nid = PWN::SDR::Decoder::DSP.bits_to_int(bits: nid_bits[0, 16])
78
+ nac = (nid >> 4) & 0xFFF
79
+ duid = nid & 0xF
80
+ @seen_fs += 1
81
+ yield(
82
+ protocol: 'P25', event: 'frame', modulation: 'C4FM',
83
+ fs_errors: err, nac: format('%03X', nac), duid: duid,
84
+ duid_name: P25::DUID_NAME[duid] || format('0x%X', duid),
85
+ nid_hex: format('%016X', PWN::SDR::Decoder::DSP.bits_to_int(bits: nid_bits)),
86
+ frame_no: @seen_fs,
87
+ summary: "P25 NAC=#{format('%03X', nac)} DUID=#{P25::DUID_NAME[duid] || duid} (fs_err=#{err})"
61
88
  )
62
- yield msg if block_given?
89
+ i += 24 + 32
90
+ else
91
+ i += 1
63
92
  end
64
- i += hop
65
93
  end
94
+ @dibits.shift([i - 24, 0].max) if i > 24
66
95
  end
67
96
  end
68
97
 
@@ -73,29 +102,16 @@ module PWN
73
102
 
74
103
  public_class_method def self.decode(opts = {})
75
104
  freq_obj = opts[:freq_obj]
76
-
77
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 480_000).to_i
78
- proto = 'P25'
79
- demod = DemodIQ.new(
80
- rate: rate, protocol: proto, modulation: 'C4FM',
81
- extra: { threshold: 6.0 }
82
- )
105
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || (48_000 * 20)).to_i
83
106
  PWN::SDR::Decoder::Base.run_iq(
84
107
  freq_obj: freq_obj,
85
- protocol: proto,
108
+ protocol: 'P25',
86
109
  sample_rate: rate,
87
110
  source: opts[:source],
88
111
  file: opts[:file],
89
- demod: demod,
90
- threshold: 6.0,
91
- note: 'C4FM 4-FSK + trellis true-air I/Q path reports key-up bursts; full NAC/TG decode layered on Liquid when available.',
92
- describe: proc { |b|
93
- { modulation: 'C4FM', classification: (if b[:duration_ms] > 1500
94
- 'voice-superframe'
95
- else
96
- (b[:duration_ms] > 150 ? 'LDU/HDR' : 'TSBK/control')
97
- end) }
98
- }
112
+ demod: DemodIQ.new(rate: rate),
113
+ note: 'C4FM 4800 sym/s — I/Q→FM→4-FSK→FS 0x5575F5FF77FF→NAC/DUID.',
114
+ describe: proc { |b| { modulation: 'C4FM', classification: b[:duration_ms] > 180 ? 'voice-LDU' : 'TSBK/control' } }
99
115
  )
100
116
  end
101
117
 
@@ -105,7 +121,7 @@ module PWN
105
121
  out[:nac] = ::Regexp.last_match(1) if line =~ /NAC[:= ]+([0-9A-Fa-f]+)/
106
122
  out[:tg] = ::Regexp.last_match(1) if line =~ /(?:TG|talkgroup)[:= ]+(\d+)/i
107
123
  out[:rid] = ::Regexp.last_match(1) if line =~ /(?:RID|src|source)[:= ]+(\d+)/i
108
- out[:sys] = ::Regexp.last_match(1) if line =~ /sys(?:tem)?[:= ]+(\w+)/i
124
+ out[:duid] = ::Regexp.last_match(1) if line =~ /DUID[:= ]+(\w+)/i
109
125
  out[:summary] = "P25 NAC=#{out[:nac]} TG=#{out[:tg]} RID=#{out[:rid]}"
110
126
  out.compact
111
127
  end