pwn 0.5.531 → 0.5.534

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.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'tty-spinner'
6
+ require 'io/wait'
7
+
8
+ module PWN
9
+ module SDR
10
+ module Decoder
11
+ # Flex Decoder Module for Pagers
12
+ module Flex
13
+ # Supported Method Parameters::
14
+ # pocsag_resp = PWN::SDR::Decoder::Flex.decode(
15
+ # freq_obj: 'required - GQRX socket object returned from #connect method'
16
+ # )
17
+
18
+ public_class_method def self.decode(opts = {})
19
+ freq_obj = opts[:freq_obj]
20
+ gqrx_sock = freq_obj[:gqrx_sock]
21
+ udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
22
+ udp_port = freq_obj[:udp_port] || 7355
23
+
24
+ freq_obj.delete(:gqrx_sock)
25
+
26
+ skip_freq_char = "\n"
27
+
28
+ puts JSON.pretty_generate(freq_obj)
29
+ puts 'Press [ENTER] to Continue to next frequency...'
30
+
31
+ # Spinner setup with dynamic terminal width awareness
32
+ spinner = TTY::Spinner.new(
33
+ '[:spinner] :status',
34
+ format: :arrow_pulse,
35
+ clear: true,
36
+ hide_cursor: true
37
+ )
38
+
39
+ spinner_overhead = 12
40
+ max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
41
+
42
+ initial_title = "INFO: Decoding #{self.to_s.split('::').last} on udp://#{udp_ip}:#{udp_port} ..."
43
+ initial_title = initial_title[0...max_title_length] if initial_title.length > max_title_length
44
+ spinner.update(status: initial_title)
45
+ spinner.auto_spin
46
+
47
+ skip_freq = false
48
+
49
+ # === Replace netcat with PWN::Plugins::Sock.listen ===
50
+ udp_listener = PWN::SDR::GQRX.listen_udp(
51
+ udp_ip: udp_ip,
52
+ udp_port: udp_port
53
+ )
54
+
55
+ # Combined processing pipeline: sox → multimon-ng
56
+ decode_cmd = '
57
+ sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - \
58
+ -t raw -e signed-integer -b 16 -r 22050 -c 1 - | \
59
+ multimon-ng -t raw \
60
+ -a FLEX \
61
+ -a FLEX_NEXT \
62
+ -
63
+ '
64
+
65
+ mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(decode_cmd)
66
+
67
+ current_title = 'Waiting for data frames...'
68
+
69
+ # Thread: read from UDP listener and feed to sox|multimon pipeline
70
+ receiver_thread = Thread.new do
71
+ begin
72
+ loop do
73
+ data, _sender = udp_listener.recv(4096)
74
+ next unless data.to_s.bytesize.positive?
75
+
76
+ mm_stdin.write(data)
77
+ mm_stdin.flush rescue nil
78
+ end
79
+ rescue IOError, Errno::EPIPE, EOFError, Errno::ECONNRESET
80
+ # normal exit path when shutting down
81
+ end
82
+ end
83
+
84
+ # Thread: read decoded output from multimon-ng and display it
85
+ decoder_thread = Thread.new do
86
+ # buffer = +''
87
+ buffer = ''
88
+
89
+ valid_types = %w[ALN BIN HEX NUM TON TONE UNK]
90
+ loop do
91
+ begin
92
+ chunk = mm_stdout.readpartial(4096)
93
+ # buffer << chunk
94
+ buffer = "#{buffer}#{chunk}"
95
+
96
+ while (line = buffer.slice!(/^.*\n/))
97
+ line = line.chomp
98
+ next if line.empty? || !line.start_with?('FLEX')
99
+
100
+ decoded_at = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
101
+ dec_msg = { decoded_at: decoded_at }
102
+ dec_msg[:raw_inspected] = line.inspect
103
+
104
+ protocol = line[0..8]
105
+ protocol = 'FLEX' unless protocol == 'FLEX_NEXT'
106
+ dec_msg[:protocol] = protocol
107
+
108
+ # ────────────────────────────── Detect format ──────────────────────────────
109
+ # Sometimes Flex is space delimited, sometimes pipe delimited
110
+ # FLEX_NEXT appears to always be pipe delimited
111
+
112
+ delimiter = '|'
113
+ space_delim = false
114
+ if line.start_with?('FLEX: ')
115
+ delimiter = ' '
116
+ space_delim = true
117
+ end
118
+
119
+ flex_pipe_delim = false
120
+ flex_pipe_delim = true if line.start_with?('FLEX|')
121
+
122
+ parts = line.split(delimiter)
123
+
124
+ # protocol index already used
125
+ idx_already_used = [0]
126
+ target_parts_idx = 1
127
+ target_parts_idx += 1 if flex_pipe_delim
128
+ target_parts_idx += 2 if space_delim
129
+ dec_msg[:speed] = parts[target_parts_idx] if parts[target_parts_idx]
130
+ idx_already_used.push(target_parts_idx)
131
+
132
+ target_parts_idx += 2
133
+ dec_msg[:capcode] = parts[target_parts_idx] if parts[target_parts_idx]
134
+ idx_already_used.push(target_parts_idx)
135
+
136
+ target_parts_idx -= 1
137
+ dec_msg[:capcode_loc] = parts[target_parts_idx] if parts[target_parts_idx]
138
+ idx_already_used.push(target_parts_idx)
139
+
140
+ while target_parts_idx < parts.size
141
+ if idx_already_used.include?(target_parts_idx)
142
+ target_parts_idx += 1
143
+ next
144
+ end
145
+
146
+ key = parts[target_parts_idx]
147
+ key = 'long_sequence_number' if key == 'LS'
148
+
149
+ if key && valid_types.include?(key)
150
+ dec_msg[:type] = key
151
+
152
+ dec_msg[:type_desc] = case key
153
+ when 'ALN'
154
+ 'Human-readable text'
155
+ when 'BIN'
156
+ 'Binary / data payload (typically 32 bit words)'
157
+ when 'HEX'
158
+ 'Raw hex representation of data'
159
+ when 'NUM'
160
+ 'Numbers only'
161
+ when 'TON', 'TONE'
162
+ 'Just alert tone, no message'
163
+ when 'UNK'
164
+ 'Decoded but type unknown / unsupported format'
165
+ end
166
+
167
+ target_parts_idx += 1
168
+ payload_parts = parts[target_parts_idx..]
169
+ dec_msg[:type_payload] = payload_parts.join(delimiter)
170
+
171
+ break
172
+ else
173
+ target_parts_idx += 1
174
+ dec_msg[key] = parts[target_parts_idx] if parts[target_parts_idx]
175
+ end
176
+
177
+ idx_already_used.push(target_parts_idx)
178
+ target_parts_idx += 1
179
+ end
180
+
181
+ final_msg = freq_obj.merge(dec_msg)
182
+ puts JSON.pretty_generate(final_msg)
183
+ # TODO: Append dec_msg to a log file in a better way
184
+ flex_log_file = "/tmp/flex_decoder_#{Time.now.strftime('%Y%m%d')}.log"
185
+ File.open(flex_log_file, 'a') do |f|
186
+ f.puts("#{JSON.generate(final_msg)},")
187
+ end
188
+ end
189
+ rescue EOFError, IOError
190
+ break
191
+ end
192
+ end
193
+ end
194
+
195
+ loop do
196
+ spinner.update(status: current_title)
197
+
198
+ # Non-blocking ENTER check to exit gracefully
199
+ next unless $stdin.wait_readable(0)
200
+
201
+ begin
202
+ char = $stdin.read_nonblock(1)
203
+ next unless char == skip_freq_char
204
+
205
+ skip_freq = true
206
+ puts "\n[!] ENTER pressed → stopping decoder..."
207
+
208
+ break
209
+ rescue IO::WaitReadable, EOFError
210
+ # ignore
211
+ end
212
+
213
+ break if skip_freq
214
+ end
215
+
216
+ spinner.success('Decoding stopped')
217
+ rescue StandardError => e
218
+ spinner.error("Decoding failed: #{e.message}") if defined?(spinner)
219
+ raise
220
+ ensure
221
+ # Cleanup
222
+ [receiver_thread, decoder_thread].each do |thread|
223
+ thread.kill if thread.alive?
224
+ end
225
+
226
+ [mm_stdin, mm_stdout, mm_stderr].each { |io| io.close rescue nil }
227
+
228
+ mm_wait_thr&.value rescue nil
229
+
230
+ PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if defined?(udp_listener) && udp_listener
231
+
232
+ spinner.stop if defined?(spinner) && spinner
233
+ end
234
+
235
+ # Author(s):: 0day Inc. <support@0dayinc.com>
236
+
237
+ public_class_method def self.authors
238
+ "AUTHOR(S):
239
+ 0day Inc. <support@0dayinc.com>
240
+ "
241
+ end
242
+
243
+ # Display Usage for this Module
244
+
245
+ public_class_method def self.help
246
+ puts "USAGE:
247
+ #{self}.decode(
248
+ freq_obj: 'required - freq_obj returned from PWN::SDR::GQRX.init_freq method'
249
+ )
250
+
251
+ #{self}.authors
252
+ "
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'open3'
4
5
  require 'tty-spinner'
6
+ require 'io/wait'
5
7
 
6
8
  module PWN
7
9
  module SDR
@@ -16,91 +18,217 @@ module PWN
16
18
  public_class_method def self.decode(opts = {})
17
19
  freq_obj = opts[:freq_obj]
18
20
  gqrx_sock = freq_obj[:gqrx_sock]
19
- record_path = freq_obj[:record_path]
20
- freq_obj = freq_obj.dup
21
+ udp_ip = freq_obj[:udp_ip] || '127.0.0.1'
22
+ udp_port = freq_obj[:udp_port] || 7355
23
+
21
24
  freq_obj.delete(:gqrx_sock)
25
+
22
26
  skip_freq_char = "\n"
23
- puts JSON.pretty_generate(freq_obj)
24
- puts "\n*** Pager POCSAG Decoder ***"
25
- puts 'Press [ENTER] to continue...'
26
-
27
- # Toggle POCSAG off and on to reset the decoder
28
- PWN::SDR::GQRX.cmd(
29
- gqrx_sock: gqrx_sock,
30
- cmd: 'U RECORD 0',
31
- resp_ok: 'RPRT 0'
32
- )
33
27
 
34
- PWN::SDR::GQRX.cmd(
35
- gqrx_sock: gqrx_sock,
36
- cmd: 'U RECORD 1',
37
- resp_ok: 'RPRT 0'
38
- )
28
+ puts JSON.pretty_generate(freq_obj)
29
+ puts 'Press [ENTER] to Continue to next frequency...'
39
30
 
40
31
  # Spinner setup with dynamic terminal width awareness
41
32
  spinner = TTY::Spinner.new(
42
- '[:spinner] :decoding',
33
+ '[:spinner] :status',
43
34
  format: :arrow_pulse,
44
35
  clear: true,
45
36
  hide_cursor: true
46
37
  )
47
38
 
48
- record_header_size = 44
49
- wav_header = File.binread(record_path, record_header_size)
50
- raise 'ERROR: WAV file header is invalid!' unless wav_header[0, 4] == 'RIFF' && wav_header[8, 4] == 'WAVE'
51
-
52
- bytes_read = wav_header_size
53
-
54
- # Conservative overhead for spinner animation, colors, and spacing
55
39
  spinner_overhead = 12
56
40
  max_title_length = [TTY::Screen.width - spinner_overhead, 50].max
57
41
 
58
- initial_title = 'INFO: Decoding Pager POCSAG data...'
42
+ initial_title = "INFO: Decoding #{self.to_s.split('::').last} on udp://#{udp_ip}:#{udp_port} ..."
59
43
  initial_title = initial_title[0...max_title_length] if initial_title.length > max_title_length
60
- spinner.update(title: initial_title)
44
+ spinner.update(status: initial_title)
61
45
  spinner.auto_spin
62
46
 
63
- last_resp = {}
47
+ skip_freq = false
64
48
 
65
- loop do
66
- current_wav_size = File.size?(record_path) ||= 0
67
- pocsag_resp = {}
49
+ # === Replace netcat with PWN::Plugins::Sock.listen ===
50
+ udp_listener = PWN::SDR::GQRX.listen_udp(
51
+ udp_ip: udp_ip,
52
+ udp_port: udp_port
53
+ )
54
+
55
+ # Combined processing pipeline: sox → multimon-ng
56
+ decode_cmd = '
57
+ sox -t raw -e signed-integer -b 16 -r 48000 -c 1 - \
58
+ -t raw -e signed-integer -b 16 -r 22050 -c 1 - | \
59
+ multimon-ng -t raw \
60
+ -a POCSAG512 \
61
+ -a POCSAG1200 \
62
+ -a POCSAG2400 \
63
+ -
64
+ '
65
+
66
+ mm_stdin, mm_stdout, mm_stderr, mm_wait_thr = Open3.popen3(decode_cmd)
68
67
 
69
- # Only update when we have valid new data
70
- if current_wav_size > bytes_read
71
- new_bytes = current_wav_size - bytes_read
72
- # Ensure full I/Q pairs (8 bytes each)
73
- new_bytes -= new_bytes % 8
74
- data = File.binread(record_path, new_bytes, bytes_read)
68
+ current_title = 'Waiting for data frames...'
75
69
 
76
- msg = "DECODED DATA"
70
+ # Thread: read from UDP listener and feed to sox|multimon pipeline
71
+ receiver_thread = Thread.new do
72
+ begin
73
+ loop do
74
+ data, _sender = udp_listener.recv(4096)
75
+ next unless data.to_s.bytesize.positive?
77
76
 
78
- spinner.update(decoding: msg)
79
- last_resp = pocsag_resp.dup
77
+ mm_stdin.write(data)
78
+ mm_stdin.flush rescue nil
79
+ end
80
+ rescue IOError, Errno::EPIPE, EOFError, Errno::ECONNRESET
81
+ # normal exit path when shutting down
80
82
  end
83
+ end
84
+
85
+ # Thread: read decoded output from multimon-ng and display it
86
+ decoder_thread = Thread.new do
87
+ # buffer = +''
88
+ buffer = ''
81
89
 
82
- # Non-blocking check for ENTER key to exit
83
- if $stdin.wait_readable(0)
90
+ valid_types = %w[ALN BIN HEX NUM TON TONE UNK]
91
+ loop do
84
92
  begin
85
- char = $stdin.read_nonblock(1)
86
- break if char == skip_freq_char
87
- rescue IO::WaitReadable, EOFError
88
- # No-op
93
+ chunk = mm_stdout.readpartial(4096)
94
+ # buffer << chunk
95
+ buffer = "#{buffer}#{chunk}"
96
+
97
+ while (line = buffer.slice!(/^.*\n/))
98
+ line = line.chomp
99
+ next if line.empty? || !line.start_with?('POCSAG')
100
+
101
+ decoded_at = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
102
+ dec_msg = { decoded_at: decoded_at }
103
+ dec_msg[:raw_inspected] = line.inspect
104
+
105
+ protocol = line[0..8]
106
+ protocol = line[0..9] unless protocol == 'POCSAG512'
107
+ dec_msg[:protocol] = protocol
108
+
109
+ # ────────────────────────────── Detect format ──────────────────────────────
110
+ # Sometimes POCSAG is space delimited, sometimes pipe delimited
111
+ # FLEX_NEXT appears to always be pipe delimited
112
+
113
+ delimiter = '|'
114
+ space_delim = false
115
+ if line.start_with?('FLEX: ')
116
+ delimiter = ' '
117
+ space_delim = true
118
+ end
119
+
120
+ flex_pipe_delim = false
121
+ flex_pipe_delim = true if line.start_with?('FLEX|')
122
+
123
+ parts = line.split(delimiter)
124
+
125
+ # protocol index already used
126
+ idx_already_used = [0]
127
+ target_parts_idx = 1
128
+ target_parts_idx += 1 if flex_pipe_delim
129
+ target_parts_idx += 2 if space_delim
130
+ dec_msg[:speed] = parts[target_parts_idx] if parts[target_parts_idx]
131
+ idx_already_used.push(target_parts_idx)
132
+
133
+ target_parts_idx += 2
134
+ dec_msg[:capcode] = parts[target_parts_idx] if parts[target_parts_idx]
135
+ idx_already_used.push(target_parts_idx)
136
+
137
+ target_parts_idx -= 1
138
+ dec_msg[:capcode_loc] = parts[target_parts_idx] if parts[target_parts_idx]
139
+ idx_already_used.push(target_parts_idx)
140
+
141
+ while target_parts_idx < parts.size
142
+ if idx_already_used.include?(target_parts_idx)
143
+ target_parts_idx += 1
144
+ next
145
+ end
146
+
147
+ key = parts[target_parts_idx]
148
+ key = 'long_sequence_number' if key == 'LS'
149
+
150
+ if key && valid_types.include?(key)
151
+ dec_msg[:type] = key
152
+
153
+ dec_msg[:type_desc] = case key
154
+ when 'ALN'
155
+ 'Human-readable text'
156
+ when 'BIN'
157
+ 'Binary / data payload (typically 32 bit words)'
158
+ when 'HEX'
159
+ 'Raw hex representation of data'
160
+ when 'NUM'
161
+ 'Numbers only'
162
+ when 'TON', 'TONE'
163
+ 'Just alert tone, no message'
164
+ when 'UNK'
165
+ 'Decoded but type unknown / unsupported format'
166
+ end
167
+
168
+ target_parts_idx += 1
169
+ payload_parts = parts[target_parts_idx..]
170
+ dec_msg[:type_payload] = payload_parts.join(delimiter)
171
+
172
+ break
173
+ else
174
+ target_parts_idx += 1
175
+ dec_msg[key] = parts[target_parts_idx] if parts[target_parts_idx]
176
+ end
177
+
178
+ idx_already_used.push(target_parts_idx)
179
+ target_parts_idx += 1
180
+ end
181
+
182
+ final_msg = freq_obj.merge(dec_msg)
183
+ puts JSON.pretty_generate(final_msg)
184
+ # TODO: Append dec_msg to a log file in a better way
185
+ flex_log_file = "/tmp/flex_decoder_#{Time.now.strftime('%Y%m%d')}.log"
186
+ File.open(flex_log_file, 'a') do |f|
187
+ f.puts("#{JSON.generate(final_msg)},")
188
+ end
189
+ end
190
+ rescue EOFError, IOError
191
+ break
89
192
  end
90
193
  end
194
+ end
195
+
196
+ loop do
197
+ spinner.update(status: current_title)
91
198
 
92
- sleep 0.01
199
+ # Non-blocking ENTER check to exit gracefully
200
+ next unless $stdin.wait_readable(0)
201
+
202
+ begin
203
+ char = $stdin.read_nonblock(1)
204
+ next unless char == skip_freq_char
205
+
206
+ skip_freq = true
207
+ puts "\n[!] ENTER pressed → stopping decoder..."
208
+
209
+ break
210
+ rescue IO::WaitReadable, EOFError
211
+ # ignore
212
+ end
213
+
214
+ break if skip_freq
93
215
  end
216
+
217
+ spinner.success('Decoding stopped')
94
218
  rescue StandardError => e
95
- spinner.error('Decoding failed') if defined?(spinner)
96
- raise e
219
+ spinner.error("Decoding failed: #{e.message}") if defined?(spinner)
220
+ raise
97
221
  ensure
98
- File.unlink(record_path) if File.exist?(record_path)
99
- PWN::SDR::GQRX.cmd(
100
- gqrx_sock: gqrx_sock,
101
- cmd: 'U RECORD 0',
102
- resp_ok: 'RPRT 0'
103
- )
222
+ # Cleanup
223
+ [receiver_thread, decoder_thread].each do |thread|
224
+ thread.kill if thread.alive?
225
+ end
226
+
227
+ [mm_stdin, mm_stdout, mm_stderr].each { |io| io.close rescue nil }
228
+
229
+ mm_wait_thr&.value rescue nil
230
+
231
+ PWN::SDR::GQRX.disconnect_udp(udp_listener: udp_listener) if defined?(udp_listener) && udp_listener
104
232
 
105
233
  spinner.stop if defined?(spinner) && spinner
106
234
  end
@@ -17,12 +17,12 @@ module PWN
17
17
  freq_obj = opts[:freq_obj]
18
18
  gqrx_sock = freq_obj[:gqrx_sock]
19
19
 
20
- freq_obj = freq_obj.dup
20
+ # freq_obj = freq_obj.dup
21
21
  freq_obj.delete(:gqrx_sock)
22
22
  skip_freq_char = "\n"
23
23
  puts JSON.pretty_generate(freq_obj)
24
24
  puts "\n*** FM Radio RDS Decoder ***"
25
- puts 'Press [ENTER] to continue...'
25
+ puts 'Press [ENTER] to continue to next frequency...'
26
26
 
27
27
  # Toggle RDS off and on to reset the decoder
28
28
  PWN::SDR::GQRX.cmd(
@@ -39,7 +39,7 @@ module PWN
39
39
 
40
40
  # Spinner setup with dynamic terminal width awareness
41
41
  spinner = TTY::Spinner.new(
42
- '[:spinner] :decoding',
42
+ '[:spinner] :status',
43
43
  format: :arrow_pulse,
44
44
  clear: true,
45
45
  hide_cursor: true
@@ -88,7 +88,7 @@ module PWN
88
88
  rt_display = "#{rt_display[0...available_for_term]}..." if rt_display.length > available_for_term
89
89
 
90
90
  msg = "#{prefix}#{rt_display}"
91
- spinner.update(decoding: msg)
91
+ spinner.update(status: msg)
92
92
  last_resp = rds_resp.dup
93
93
  end
94
94
 
@@ -108,6 +108,13 @@ module PWN
108
108
  spinner.error('Decoding failed') if defined?(spinner)
109
109
  raise e
110
110
  ensure
111
+ # Toggle RDS off and on to reset the decoder
112
+ PWN::SDR::GQRX.cmd(
113
+ gqrx_sock: gqrx_sock,
114
+ cmd: 'U RDS 0',
115
+ resp_ok: 'RPRT 0'
116
+ )
117
+
111
118
  spinner.stop if defined?(spinner) && spinner
112
119
  end
113
120
 
@@ -7,6 +7,7 @@ module PWN
7
7
  module SDR
8
8
  # Deocder Module for SDR signals.
9
9
  module Decoder
10
+ autoload :Flex, 'pwn/sdr/decoder/flex'
10
11
  autoload :GSM, 'pwn/sdr/decoder/gsm'
11
12
  autoload :POCSAG, 'pwn/sdr/decoder/pocsag'
12
13
  autoload :RDS, 'pwn/sdr/decoder/rds'
@@ -417,7 +417,7 @@ module PWN
417
417
  bandwidth: '16.000',
418
418
  precision: 4
419
419
  },
420
- pager: {
420
+ pager_all: {
421
421
  ranges: [
422
422
  # Low-power / unlicensed / CB-related (very limited POCSAG usage)
423
423
  # RCRS channels (shared data/telemetry, occasional POCSAG)
@@ -449,6 +449,35 @@ module PWN
449
449
  ],
450
450
  demodulator_mode: :FM,
451
451
  bandwidth: '25.000',
452
+ precision: 4
453
+ },
454
+ pager_flex: {
455
+ ranges: [
456
+ # 900 MHz exclusive paging band (mostly FLEX, but some legacy POCSAG remains)
457
+ # Primary nationwide commercial paging band
458
+ { start_freq: '929.000.000', target_freq: '932.000.000' }
459
+ ],
460
+ demodulator_mode: :FM,
461
+ bandwidth: '20.000',
462
+ precision: 4,
463
+ decoder: :flex
464
+ },
465
+ pager_pocsag: {
466
+ ranges: [
467
+ # High-band VHF paging — most common for public-safety POCSAG today
468
+ # Classic VHF paging band (152/157/158 MHz very common)
469
+ { start_freq: '152.000.000', target_freq: '159.000.000' },
470
+
471
+ # UHF paging bands (hospital, on-site, restaurant/coaster pagers, some public safety)
472
+ # UHF low — occasional private systems
473
+ { start_freq: '400.000.000', target_freq: '430.000.000' },
474
+ # UHF high — very common for local POCSAG (incl. 450–470 & 467.xxx for on-site)
475
+ { start_freq: '440.000.000', target_freq: '470.000.000' },
476
+ # Classic UHF paging allocation (454/459 MHz pairs — many reallocated)
477
+ { start_freq: '454.000.000', target_freq: '460.000.000' }
478
+ ],
479
+ demodulator_mode: :FM,
480
+ bandwidth: '12.500',
452
481
  precision: 4,
453
482
  decoder: :pocsag
454
483
  },