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,65 +3,176 @@
3
3
  module PWN
4
4
  module SDR
5
5
  module Decoder
6
- # GSM (2G) true-air BCCH/CCCH activity decoder.
6
+ # GSM (2G) true-air FCCH/SCH decoder.
7
+ #
8
+ # 270.833 kbit/s GMSK. FCCH burst = 148 all-zero bits → a pure
9
+ # +67.708 kHz tone for ~547 μs. Detect via variance-dip on the FM
10
+ # discriminator (PWN::FFI::Liquid.freq_demod), estimate carrier
11
+ # offset from mean deviation, then correlate the SCH 64-bit extended
12
+ # training sequence 8 timeslots later and recover the 6-bit BSIC
13
+ # (NCC/BCC) + 19-bit reduced frame number (T1/T2/T3'). Emits
14
+ # {event:'fcch'|'sch', freq_offset_hz:, bsic:, ncc:, bcc:, rfn:}.
7
15
  module GSM
8
- # Streaming I/Q energy/burst demod for Base.run_iq.
16
+ SYMBOL_RATE = 270_833.0
17
+ FCCH_TONE = SYMBOL_RATE / 4.0 # 67.708 kHz above carrier
18
+ FCCH_BITS = 148
19
+ # SCH extended training sequence (64 bits, TS 45.002 Table 5.2.5)
20
+ SCH_ETSC = [
21
+ 1, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 1, 0,
22
+ 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1,
23
+ 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1,
24
+ 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 1, 1
25
+ ].freeze
26
+
27
+ # Streaming GMSK demod for Base.run_iq — I/Q → FCCH lock + SCH BSIC.
9
28
  class DemodIQ
10
- def initialize(rate:, protocol:, modulation:, extra: {})
11
- @rate = rate.to_f
12
- @protocol = protocol
13
- @modulation = modulation
14
- @extra = extra
15
- @floor = nil
16
- @in_burst = false
17
- @burst_t0 = nil
18
- @peak = -200.0
19
- @burst_n = 0
20
- @threshold = (extra[:threshold] || 8.0).to_f
21
- @carry = []
29
+ def initialize(rate:)
30
+ @rate = rate.to_f
31
+ @spb = @rate / SYMBOL_RATE
32
+ @audio = []
33
+ @fcch_n = 0
34
+ @sch_n = 0
22
35
  end
23
36
 
24
37
  def feed_iq(samples, rate: nil, &)
25
38
  @rate = rate.to_f if rate
26
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
27
- # process in ~1 ms hops
28
- hop = [(@rate / 1000.0).round, 1].max
39
+ @spb = @rate / SYMBOL_RATE
40
+ fm = PWN::SDR::Decoder::DSP.fm_demod_iq(iq: samples)
41
+ @audio.concat(fm)
42
+ scan(&) if block_given?
43
+ max = (@rate * 0.25).to_i
44
+ @audio.shift(@audio.length - max) if @audio.length > max
45
+ end
46
+
47
+ private
48
+
49
+ def scan
50
+ win = (FCCH_BITS * @spb).round
51
+ return if @audio.length < win * 4
52
+
53
+ # slide half-burst hop, look for min-variance window (pure tone)
54
+ hop = win / 4
55
+ best = nil
29
56
  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
57
+ while i < @audio.length - win
58
+ seg = @audio[i, win]
59
+ mean = seg.sum / seg.length
60
+ var = seg.sum { |v| (v - mean)**2 } / seg.length
61
+ best = { i: i, mean: mean, var: var } if best.nil? || var < best[:var]
62
+ i += hop
63
+ end
64
+ return unless best
65
+
66
+ # FCCH criterion: variance ≪ overall variance AND mean > 0.
67
+ g_mean = @audio.sum / @audio.length
68
+ g_var = @audio.sum { |v| (v - g_mean)**2 } / @audio.length
69
+ return unless g_var.positive? && (best[:var] / g_var) < 0.15 && best[:mean].positive?
70
+
71
+ @fcch_n += 1
72
+ # discriminator output ≈ 2π·Δf/fs → Δf = mean · fs / (2π)
73
+ f_est = best[:mean] * @rate / (2 * Math::PI)
74
+ f_off = (f_est - FCCH_TONE).round
75
+ yield(
76
+ protocol: 'GSM', event: 'fcch', modulation: 'GMSK',
77
+ symbol_rate: SYMBOL_RATE.to_i, fcch_no: @fcch_n,
78
+ tone_hz: f_est.round, freq_offset_hz: f_off,
79
+ variance_ratio: (best[:var] / g_var).round(4),
80
+ summary: "GSM FCCH lock ##{@fcch_n} tone=#{f_est.round}Hz Δf=#{f_off}Hz"
81
+ )
82
+
83
+ # SCH lives 8 timeslots after FCCH: 8 × 156.25 = 1250 symbols.
84
+ sch_off = best[:i] + (1250 * @spb).round
85
+ return unless sch_off + (148 * @spb).round < @audio.length
86
+
87
+ seg = @audio[sch_off, (156 * @spb).round]
88
+ bits = PWN::SDR::Decoder::DSP.nrz_slice(
89
+ samples: seg, rate: @rate, baud: SYMBOL_RATE
90
+ )
91
+ # GMSK data are differentially encoded (1 = no phase change).
92
+ db = PWN::SDR::Decoder::DSP.diff_decode(bits: bits).map { |b| b ^ 1 }
93
+ idx = PWN::SDR::Decoder::DSP.find_sync(
94
+ bits: db, pattern: SCH_ETSC, max_err: 8
95
+ )
96
+ return unless idx && idx >= 42 && idx + 64 + 39 <= db.length
97
+
98
+ # SCH burst: 3 tail + 39 enc + 64 TS + 39 enc + 3 tail + 8.25 guard
99
+ enc1 = db[idx - 39, 39]
100
+ enc2 = db[idx + 64, 39]
101
+ enc = enc1 + enc2 # 78 coded bits (rate-1/2 conv, K=5)
102
+ info = GSM.viterbi_decode(bits: enc, k: 5, g0: 0o23, g1: 0o33)
103
+ # 25 info bits + 10 parity + 4 tail = 39 → we get first 39.
104
+ bsic = PWN::SDR::Decoder::DSP.bits_to_int(bits: info[0, 6])
105
+ t1 = PWN::SDR::Decoder::DSP.bits_to_int(bits: info[6, 11])
106
+ t2 = PWN::SDR::Decoder::DSP.bits_to_int(bits: info[17, 5])
107
+ t3p = PWN::SDR::Decoder::DSP.bits_to_int(bits: info[22, 3])
108
+ @sch_n += 1
109
+ yield(
110
+ protocol: 'GSM', event: 'sch', modulation: 'GMSK',
111
+ bsic: bsic, ncc: (bsic >> 3) & 7, bcc: bsic & 7,
112
+ t1: t1, t2: t2, t3p: t3p, sch_no: @sch_n,
113
+ raw_info_hex: info[0, 25].each_slice(4).map { |n| PWN::SDR::Decoder::DSP.bits_to_int(bits: n).to_s(16) }.join,
114
+ summary: "GSM SCH BSIC=#{bsic} (NCC=#{(bsic >> 3) & 7} BCC=#{bsic & 7}) T1=#{t1} T2=#{t2} T3'=#{t3p}"
115
+ )
116
+ # consume up to & incl. SCH so we don't re-emit on next feed
117
+ @audio.shift(sch_off + (156 * @spb).round)
118
+ end
119
+ end
120
+
121
+ # Supported Method Parameters::
122
+ # bits = PWN::SDR::Decoder::GSM.viterbi_decode(
123
+ # bits: 'required - Array<0|1> soft/hard coded bits (rate-1/2)',
124
+ # k: 5, g0: 0o23, g1: 0o33
125
+ # )
126
+ # Minimal hard-decision K=5 rate-½ Viterbi (GSM 05.03 CC(2,1,5)).
127
+
128
+ public_class_method def self.viterbi_decode(opts = {})
129
+ bits = opts[:bits]
130
+ k = (opts[:k] || 5).to_i
131
+ g0 = (opts[:g0] || 0o23).to_i
132
+ g1 = (opts[:g1] || 0o33).to_i
133
+ nstates = 1 << (k - 1)
134
+ npairs = bits.length / 2
135
+ pm = Array.new(nstates, 1 << 30)
136
+ pm[0] = 0
137
+ bp = Array.new(npairs) { Array.new(nstates, 0) }
138
+ npairs.times do |t|
139
+ r0 = bits[t * 2]
140
+ r1 = bits[(t * 2) + 1]
141
+ npm = Array.new(nstates, 1 << 30)
142
+ nstates.times do |s|
143
+ [0, 1].each do |u|
144
+ reg = (u << (k - 1)) | s
145
+ o0 = parity(reg & g0)
146
+ o1 = parity(reg & g1)
147
+ m = pm[s] + (o0 == r0 ? 0 : 1) + (o1 == r1 ? 0 : 1)
148
+ ns = reg >> 1
149
+ if m < npm[ns]
150
+ npm[ns] = m
151
+ bp[t][ns] = (s << 1) | u
43
152
  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)
56
- msg[:summary] = format(
57
- '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
58
- p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
59
- )
60
- yield msg if block_given?
61
153
  end
62
- i += hop
63
154
  end
155
+ pm = npm
64
156
  end
157
+ # traceback from best final state
158
+ s = pm.each_with_index.min_by(&:first).last
159
+ out = Array.new(npairs)
160
+ (npairs - 1).downto(0) do |t|
161
+ v = bp[t][s]
162
+ out[t] = v & 1
163
+ s = v >> 1
164
+ end
165
+ out
166
+ end
167
+
168
+ public_class_method def self.parity(opts = {})
169
+ parity = opts[:parity].to_i
170
+ p = 0
171
+ while parity.positive?
172
+ p ^= 1
173
+ parity &= parity - 1
174
+ end
175
+ p
65
176
  end
66
177
 
67
178
  # Supported Method Parameters::
@@ -71,24 +182,16 @@ module PWN
71
182
 
72
183
  public_class_method def self.decode(opts = {})
73
184
  freq_obj = opts[:freq_obj]
74
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 1_000_000).to_i
75
- proto = 'GSM'
76
- extra = {}
77
- describe = proc { |b| { modulation: 'GMSK', symbol_rate: 270_833, tdma_frames: (b[:duration_ms] / 4.615).round, classification: (b[:duration_ms] / 4.615) > 10 ? 'BCCH/CCCH-continuous' : 'RACH/paging-burst' } }
78
- demod = DemodIQ.new(
79
- rate: rate, protocol: proto, modulation: 'GMSK',
80
- extra: { threshold: 5.0 }.merge(extra)
81
- )
185
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 1_083_333).to_i
82
186
  PWN::SDR::Decoder::Base.run_iq(
83
187
  freq_obj: freq_obj,
84
- protocol: proto,
188
+ protocol: 'GSM',
85
189
  sample_rate: rate,
86
190
  source: opts[:source],
87
191
  file: opts[:file],
88
- demod: demod,
89
- threshold: 5.0,
90
- note: '270.833 kbit/s GMSK true-air path streams I/Q from RTL-SDR/Pluto and characterises TDMA burst duty; full Viterbi/BCCH SI decode is layered on Liquid when available.',
91
- describe: describe
192
+ demod: DemodIQ.new(rate: rate),
193
+ note: '270.833 kbit/s GMSK — I/Q→freqdem→FCCH tone lock→SCH TS correlate→Viterbi→BSIC/RFN.',
194
+ describe: proc { |b| { modulation: 'GMSK', tdma_frames: (b[:duration_ms] / 4.615).round } }
92
195
  )
93
196
  end
94
197
 
@@ -115,7 +218,6 @@ module PWN
115
218
  bits << "MCC/MNC=#{out[:mcc]}/#{out[:mnc]}" if out[:mcc]
116
219
  bits << "LAC=#{out[:lac]} CI=#{out[:cell_id]}" if out[:lac]
117
220
  bits << "IMSI=#{out[:imsi]}" if out[:imsi]
118
- bits << "TMSI=#{out[:tmsi]}" if out[:tmsi]
119
221
  out[:summary] = "GSM #{bits.join(' ')}".strip
120
222
  out
121
223
  end
@@ -131,9 +233,11 @@ module PWN
131
233
  #{self}.decode(
132
234
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
133
235
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
134
- file: 'optional - .cu8/.cs16 capture'
236
+ file: 'optional - .cu8/.cs16 capture (≥1.0833 Msps)'
135
237
  )
136
238
 
239
+ #{self}.viterbi_decode(bits:, k: 5, g0: 0o23, g1: 0o33)
240
+
137
241
  #{self}.authors
138
242
  "
139
243
  end
@@ -5,66 +5,125 @@ require 'json'
5
5
  module PWN
6
6
  module SDR
7
7
  module Decoder
8
- # True-air + detector-fallback decoder for LoRa.
9
- # Prefers PWN::FFI I/Q (RTL-SDR / ADALM-Pluto / HackRF / capture file)
10
- # via Base.run_iq; degrades to Base.run_detector with no hardware.
8
+ # LoRa (Semtech CSS) true-air preamble/sync-word decoder.
9
+ #
10
+ # I/Q resampled so fs = BW (default 125 kHz), one complex sample per
11
+ # chirp step. For each SF ∈ 7..12: dechirp with a reference
12
+ # down-chirp (DSP.cmul + PWN::FFI::FFTW.cfft), find ≥6 consecutive
13
+ # symbols whose FFT-argmax bin is identical (preamble), then read
14
+ # the two sync-word symbols and two SFD down-chirps. Emits
15
+ # {sf:, bw_hz:, sync_word:, preamble_len:, cfo_bins:} — the same
16
+ # metadata gr-lora / rtl-lora surface, no external binary.
11
17
  module LoRa
12
- # Streaming I/Q energy/burst demod for Base.run_iq.
18
+ DEFAULT_BW = 125_000
19
+ SF_RANGE = (7..12)
20
+ # Public LoRaWAN sync = 0x34; private/Meshtastic default = 0x12.
21
+ KNOWN_SYNC = { 0x34 => 'LoRaWAN', 0x12 => 'private/RadioLib' }.freeze
22
+
23
+ # Streaming CSS dechirp demod for Base.run_iq.
13
24
  class DemodIQ
14
- def initialize(rate:, protocol:, modulation:, extra: {})
25
+ def initialize(rate:, bw: DEFAULT_BW)
15
26
  @rate = rate.to_f
16
- @protocol = protocol
17
- @modulation = modulation
18
- @extra = extra
19
- @floor = nil
20
- @in_burst = false
21
- @burst_t0 = nil
22
- @peak = -200.0
23
- @burst_n = 0
24
- @threshold = (extra[:threshold] || 8.0).to_f
27
+ @bw = bw.to_i
28
+ @buf = []
29
+ @dchirp = {}
30
+ @seen = {}
25
31
  end
26
32
 
27
33
  def feed_iq(samples, rate: nil, &)
28
34
  @rate = rate.to_f if rate
29
- m2 = PWN::SDR::Decoder::DSP.mag_sq(iq: samples)
30
- hop = [(@rate / 1000.0).round, 1].max
31
- i = 0
32
- while i < m2.length
33
- win = m2[i, hop]
34
- break if win.nil? || win.empty?
35
-
36
- ms = win.sum / win.length
37
- lvl = ms.positive? ? (10.0 * Math.log10(ms)) : -120.0
38
- @floor = @floor.nil? ? lvl : ((@floor * 0.98) + (lvl * 0.02))
39
- delta = lvl - @floor
40
- if delta >= @threshold
41
- unless @in_burst
42
- @in_burst = true
43
- @burst_t0 = Time.now
44
- @peak = lvl
35
+ r = PWN::SDR::Decoder::DSP.resample_iq(
36
+ iq: samples, src_rate: @rate, dst_rate: @bw
37
+ )
38
+ @buf.concat(r)
39
+ SF_RANGE.each { |sf| try_sf(sf, &) if block_given? }
40
+ max = ((1 << 12) * 20) * 2
41
+ @buf.shift(@buf.length - max) if @buf.length > max
42
+ end
43
+
44
+ private
45
+
46
+ def down_chirp(sf)
47
+ @dchirp[sf] ||= begin
48
+ n = 1 << sf
49
+ iq = Array.new(n * 2)
50
+ n.times do |k|
51
+ # base up-chirp φ(k) = π·k·(k/N − 1); down-chirp = conj.
52
+ ph = Math::PI * k * ((k.to_f / n) - 1.0)
53
+ iq[k * 2] = Math.cos(ph)
54
+ iq[(k * 2) + 1] = -Math.sin(ph)
55
+ end
56
+ iq
57
+ end
58
+ end
59
+
60
+ def demod_symbol(iq, sf)
61
+ n = 1 << sf
62
+ dc = down_chirp(sf)
63
+ de = PWN::SDR::Decoder::DSP.cmul(a: iq, b: dc)
64
+ mag = PWN::SDR::Decoder::DSP.cfft_mag(iq: de, n: n, shift: false)
65
+ pk_i = mag.each_with_index.max_by(&:first).last
66
+ [pk_i, mag[pk_i], mag.sum / mag.length]
67
+ end
68
+
69
+ def try_sf(sf)
70
+ n = 1 << sf
71
+ need = n * 12 * 2 # ≥ 8 preamble + 2 sync + 2.25 SFD
72
+ return if @buf.length < need
73
+
74
+ # coarse alignment: try 8 phase offsets across first symbol
75
+ best_off = 0
76
+ best_run = 0
77
+ best_bin = nil
78
+ (0...n).step([n / 8, 1].max) do |off|
79
+ bins = []
80
+ 10.times do |s|
81
+ seg = @buf[(off + (s * n)) * 2, n * 2]
82
+ break if seg.nil? || seg.length < n * 2
83
+
84
+ bins << demod_symbol(seg, sf).first
85
+ end
86
+ # longest run of equal bins
87
+ run = 1
88
+ cur = 1
89
+ (1...bins.length).each do |i|
90
+ if ((bins[i] - bins[i - 1]) % n).zero?
91
+ cur += 1
92
+ run = cur if cur > run
93
+ else
94
+ cur = 1
45
95
  end
46
- @peak = lvl if lvl > @peak
47
- elsif @in_burst
48
- @in_burst = false
49
- @burst_n += 1
50
- dur_ms = ((Time.now - @burst_t0) * 1000).round
51
- msg = {
52
- protocol: @protocol, event: 'burst', source: 'iq',
53
- burst_no: @burst_n, peak_dbfs: @peak.round(1),
54
- floor_dbfs: @floor.round(1), delta_db: (@peak - @floor).round(1),
55
- duration_ms: dur_ms, modulation: @modulation,
56
- sample_rate: @rate.to_i
57
- }.merge(@extra.except(:threshold))
58
- # protocol-specific enrichment
59
- msg.merge!(self.class.enrich(msg)) if self.class.respond_to?(:enrich)
60
- msg[:summary] = format(
61
- '%<p>s IQ-burst #%<n>d peak=%<pk>+.1f dBFS Δ=%<d>.1f dB dur=%<ms>d ms',
62
- p: @protocol, n: @burst_n, pk: @peak, d: @peak - @floor, ms: dur_ms
63
- )
64
- yield msg if block_given?
65
96
  end
66
- i += hop
97
+ if run > best_run
98
+ best_run = run
99
+ best_off = off
100
+ best_bin = bins.group_by { |x| x }.max_by { |_, v| v.length }&.first
101
+ end
67
102
  end
103
+ return unless best_run >= 6 && best_bin
104
+
105
+ # sync symbols follow preamble; walk forward until bin changes
106
+ i = best_off
107
+ i += n while ((demod_symbol(@buf[i * 2, n * 2], sf).first - best_bin) % n).zero? && i < (@buf.length / 2) - (4 * n)
108
+ s1 = demod_symbol(@buf[i * 2, n * 2], sf).first
109
+ s2 = demod_symbol(@buf[(i + n) * 2, n * 2], sf).first
110
+ # sync word nibble encoding (× 2^(SF-4)); recover both nibbles.
111
+ div = 1 << (sf - 4)
112
+ n1 = (((s1 - best_bin) % n) / div) & 0xF
113
+ n2 = (((s2 - best_bin) % n) / div) & 0xF
114
+ sync = (n1 << 4) | n2
115
+ key = "SF#{sf}:#{format('%02X', sync)}"
116
+ unless @seen[key]
117
+ @seen[key] = true
118
+ yield(
119
+ protocol: 'LoRa', event: 'preamble', modulation: 'CSS',
120
+ sf: sf, bw_hz: @bw, preamble_len: best_run,
121
+ cfo_bins: best_bin, sync_word: format('0x%02X', sync),
122
+ sync_name: KNOWN_SYNC[sync],
123
+ summary: "LoRa SF#{sf}/BW#{@bw / 1000}k sync=0x#{format('%02X', sync)}#{" (#{KNOWN_SYNC[sync]})" if KNOWN_SYNC[sync]} preamble=#{best_run}"
124
+ )
125
+ end
126
+ @buf.shift((i + (2 * n)) * 2)
68
127
  end
69
128
  end
70
129
 
@@ -75,23 +134,17 @@ module PWN
75
134
 
76
135
  public_class_method def self.decode(opts = {})
77
136
  freq_obj = opts[:freq_obj]
78
-
79
- rate = (opts[:sample_rate] || freq_obj[:iq_rate] || 1_000_000).to_i
80
- proto = 'LoRa'
81
- demod = DemodIQ.new(
82
- rate: rate, protocol: proto, modulation: 'CSS',
83
- extra: { threshold: 8.0 }
84
- )
137
+ bw = (opts[:bw] || freq_obj[:lora_bw] || DEFAULT_BW).to_i
138
+ rate = (opts[:sample_rate] || freq_obj[:iq_rate] || (bw * 4)).to_i
85
139
  PWN::SDR::Decoder::Base.run_iq(
86
140
  freq_obj: freq_obj,
87
- protocol: proto,
141
+ protocol: 'LoRa',
88
142
  sample_rate: rate,
89
143
  source: opts[:source],
90
144
  file: opts[:file],
91
- demod: demod,
92
- threshold: 8.0,
93
- note: 'CSS over 125–500 kHz true-air I/Q path estimates SF from chirp duration; full dechirp via FFTW is layered when available.',
94
- describe: proc { |b| { modulation: 'CSS', bw_khz_assumed: 125, sf_estimate: (begin; t = b[:duration_ms] / 20.0; t.positive? ? Math.log2(t * 125).round.clamp(6, 12) : nil; end) }.compact }
145
+ demod: DemodIQ.new(rate: rate, bw: bw),
146
+ note: 'CSS 125–500 kHz — I/Q→resample_iq(fs=BW)→dechirp+FFTW per SF→preamble/sync-word.',
147
+ describe: proc { |b| { modulation: 'CSS', bw_khz_assumed: bw / 1000, sf_estimate: b[:sf] } }
95
148
  )
96
149
  end
97
150
 
@@ -106,8 +159,7 @@ module PWN
106
159
  bits = []
107
160
  bits << "SF#{out[:sf]}" if out[:sf]
108
161
  bits << "BW#{out[:bw]}" if out[:bw]
109
- bits << "CR#{out[:cr]}" if out[:cr]
110
- bits << "RSSI=#{out[:rssi]}" if out[:rssi]
162
+ bits << "sync=#{out[:sync_word]}" if out[:sync_word]
111
163
  bits << out[:payload].to_s[0, 40] if out[:payload]
112
164
  out[:summary] = bits.empty? ? line[0, 120] : bits.join(' ')
113
165
  out
@@ -124,7 +176,8 @@ module PWN
124
176
  #{self}.decode(
125
177
  freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq',
126
178
  source: 'optional - :auto|:rtlsdr|:adalm_pluto|:file',
127
- file: 'optional - .cu8/.cs16 capture'
179
+ file: 'optional - .cu8/.cs16 capture',
180
+ bw: 'optional - LoRa BW Hz (default 125000)'
128
181
  )
129
182
 
130
183
  #{self}.authors