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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +20 -30
- data/Gemfile +1 -1
- data/README.md +2 -2
- data/lib/pwn/ai/agent/dispatch.rb +76 -4
- data/lib/pwn/ai/agent/learning.rb +124 -1
- data/lib/pwn/ai/agent/loop.rb +104 -12
- data/lib/pwn/ai/agent/metrics.rb +60 -27
- data/lib/pwn/ai/agent/prompt_builder.rb +63 -12
- data/lib/pwn/ai/agent/reflect.rb +41 -7
- data/lib/pwn/ai/agent/registry.rb +69 -4
- data/lib/pwn/ai/ollama.rb +39 -10
- data/lib/pwn/config.rb +11 -0
- data/lib/pwn/ffi/liquid.rb +179 -0
- data/lib/pwn/memory.rb +15 -0
- data/lib/pwn/memory_index.rb +233 -0
- data/lib/pwn/sdr/decoder/bluetooth.rb +141 -75
- data/lib/pwn/sdr/decoder/dect.rb +89 -63
- data/lib/pwn/sdr/decoder/dsp.rb +443 -0
- data/lib/pwn/sdr/decoder/gps.rb +93 -73
- data/lib/pwn/sdr/decoder/gsm.rb +167 -63
- data/lib/pwn/sdr/decoder/lora.rb +118 -65
- data/lib/pwn/sdr/decoder/lte.rb +188 -65
- data/lib/pwn/sdr/decoder/p25.rb +83 -67
- data/lib/pwn/sdr/decoder/zigbee.rb +140 -67
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +1 -0
- data/spec/lib/pwn/memory_index_spec.rb +15 -0
- data/third_party/pwn_rdoc.jsonl +65 -8
- metadata +6 -4
data/lib/pwn/sdr/decoder/gsm.rb
CHANGED
|
@@ -3,65 +3,176 @@
|
|
|
3
3
|
module PWN
|
|
4
4
|
module SDR
|
|
5
5
|
module Decoder
|
|
6
|
-
# GSM (2G) true-air
|
|
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
|
-
|
|
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
|
|
11
|
-
@rate
|
|
12
|
-
@
|
|
13
|
-
@
|
|
14
|
-
@
|
|
15
|
-
@
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 <
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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] ||
|
|
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:
|
|
188
|
+
protocol: 'GSM',
|
|
85
189
|
sample_rate: rate,
|
|
86
190
|
source: opts[:source],
|
|
87
191
|
file: opts[:file],
|
|
88
|
-
demod:
|
|
89
|
-
|
|
90
|
-
|
|
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
|
data/lib/pwn/sdr/decoder/lora.rb
CHANGED
|
@@ -5,66 +5,125 @@ require 'json'
|
|
|
5
5
|
module PWN
|
|
6
6
|
module SDR
|
|
7
7
|
module Decoder
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
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:,
|
|
25
|
+
def initialize(rate:, bw: DEFAULT_BW)
|
|
15
26
|
@rate = rate.to_f
|
|
16
|
-
@
|
|
17
|
-
@
|
|
18
|
-
@
|
|
19
|
-
@
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
141
|
+
protocol: 'LoRa',
|
|
88
142
|
sample_rate: rate,
|
|
89
143
|
source: opts[:source],
|
|
90
144
|
file: opts[:file],
|
|
91
|
-
demod:
|
|
92
|
-
|
|
93
|
-
|
|
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 << "
|
|
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
|