meshtastic 0.0.169 → 0.0.171

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.
@@ -6,81 +6,222 @@ require 'io/wait'
6
6
  require 'json'
7
7
  require 'openssl'
8
8
  require 'securerandom'
9
+ require 'timeout'
9
10
  require 'uart'
10
11
 
11
- # Plugin used to interact with Meshtastic nodes
12
+ # Plugin used to interact with Meshtastic nodes over a serial (UART) link.
13
+ # Wire protocol matches the official Python client:
14
+ # [START1=0x94][START2=0xC3][len_hi][len_lo] + protobuf(ToRadio|FromRadio)
12
15
  module Meshtastic
13
- module SerialInterface
16
+ module SerialInterface # rubocop:disable Metrics/ModuleLength
14
17
  @console_data = []
15
18
  @proto_data = []
19
+ @from_radio_queue = nil
20
+ @rx_mutex = Mutex.new
21
+ @want_exit = false
22
+
23
+ module_function
24
+
25
+ # ---- low-level IO helpers ------------------------------------------------
26
+
27
+ def self.clear_hupcl(block_dev)
28
+ # Prevent device reboot on open by clearing HUPCL (same as Python pyserial path).
29
+ return unless defined?(Termios)
30
+
31
+ File.open(block_dev, File::RDWR | Fcntl::O_NOCTTY | Fcntl::O_NDELAY) do |f|
32
+ attrs = Termios.tcgetattr(f)
33
+ attrs.cflag &= ~Termios::HUPCL if defined?(Termios::HUPCL)
34
+ Termios.tcsetattr(f, Termios::TCSAFLUSH, attrs)
35
+ end
36
+ sleep 0.1
37
+ rescue StandardError
38
+ # Best-effort — uart.open will still work without this.
39
+ nil
40
+ end
41
+ private_class_method :clear_hupcl
16
42
 
17
43
  # Supported Method Parameters::
18
- # proto_thread = init_stdout_thread(
19
- # serial_conn: 'required - serial_conn object returned from UART.open method',
20
- # type: 'required - :proto or :console'
44
+ # proto_thread = init_rx_thread(
45
+ # serial_conn: 'required - File returned from UART.open',
46
+ # serial_obj: 'required - serial_obj hash being built'
21
47
  # )
22
-
23
- private_class_method def self.init_stdout_thread(opts = {})
48
+ private_class_method def self.init_rx_thread(opts = {})
24
49
  serial_conn = opts[:serial_conn]
25
- type = opts[:type]
26
- valid_types = %i[proto console]
27
- raise "ERROR: Invalid type: #{type}. Supported types are :proto or :console" unless valid_types.include?(type)
50
+ serial_obj = opts[:serial_obj]
51
+ debug_out = opts[:debug_out]
52
+
53
+ @want_exit = false
54
+ @from_radio_queue = Queue.new
55
+ @console_data = []
56
+ @proto_data = []
28
57
 
29
- # Spin up a serial_obj console_thread
30
58
  Thread.new do
31
- # serial_conn.read_timeout = -1
32
- serial_conn.flush
33
- from_radio = Meshtastic::FromRadio.new
34
-
35
- loop do
36
- serial_conn.wait_readable
37
- # Read raw chars into @console_data,
38
- # convert to readable bytes if need-be
39
- # later.
40
- @proto_data << from_radio.to_h if type == :proto
41
- @console_data << serial_conn.readchar.force_encoding('UTF-8') if type == :console
59
+ Thread.current.abort_on_exception = false
60
+ rx_buf = +''.b
61
+ empty = +''.b
62
+
63
+ until @want_exit
64
+ begin
65
+ chunk = serial_conn.read(1)
66
+ if chunk.nil? || chunk.empty?
67
+ sleep 0.01
68
+ next
69
+ end
70
+
71
+ c = chunk.getbyte(0)
72
+ rx_buf << chunk
73
+ ptr = rx_buf.bytesize - 1
74
+
75
+ if ptr.zero?
76
+ # looking for START1
77
+ unless c == Meshtastic::START1
78
+ rx_buf = empty.dup
79
+ append_console_byte(chunk, debug_out)
80
+ end
81
+ elsif ptr == 1
82
+ # looking for START2
83
+ rx_buf = empty.dup unless c == Meshtastic::START2
84
+ elsif ptr >= (Meshtastic::HEADER_LEN - 1)
85
+ packet_len = (rx_buf.getbyte(2) << 8) + rx_buf.getbyte(3)
86
+
87
+ if ptr == (Meshtastic::HEADER_LEN - 1) && packet_len > Meshtastic::MAX_TO_FROM_RADIO_SIZE
88
+ rx_buf = empty.dup
89
+ next
90
+ end
91
+
92
+ if rx_buf.bytesize >= (packet_len + Meshtastic::HEADER_LEN)
93
+ payload = rx_buf.byteslice(Meshtastic::HEADER_LEN, packet_len)
94
+ rx_buf = empty.dup
95
+ handle_from_radio_bytes(payload: payload, serial_obj: serial_obj)
96
+ end
97
+ end
98
+ rescue IOError, Errno::EBADF, Errno::EIO
99
+ break if @want_exit
100
+
101
+ sleep 0.05
102
+ rescue StandardError => e
103
+ warn "Meshtastic::SerialInterface RX error: #{e.class}: #{e.message}" unless @want_exit
104
+ sleep 0.05
105
+ end
42
106
  end
43
107
  end
44
- rescue StandardError => e
45
- raise e
46
108
  end
47
109
 
110
+
111
+ private_class_method def self.append_console_byte(chunk, debug_out)
112
+ if debug_out
113
+ begin
114
+ debug_out.write(chunk.force_encoding('UTF-8'))
115
+ rescue StandardError
116
+ debug_out.write('?')
117
+ end
118
+ else
119
+ @rx_mutex.synchronize { @console_data << chunk.force_encoding('UTF-8') }
120
+ end
121
+ end
122
+
123
+ private_class_method def self.handle_from_radio_bytes(opts = {})
124
+ payload = opts[:payload]
125
+ serial_obj = opts[:serial_obj]
126
+ return if payload.nil? || payload.empty?
127
+
128
+ from_radio = Meshtastic::FromRadio.decode(payload)
129
+ hash = from_radio.to_h
130
+
131
+ @rx_mutex.synchronize { @proto_data << hash }
132
+ @from_radio_queue << from_radio if @from_radio_queue
133
+
134
+ # Cache useful device identity on the serial_obj handle.
135
+ if serial_obj && from_radio.my_info
136
+ serial_obj[:my_info] = from_radio.my_info.to_h
137
+ serial_obj[:my_node_num] = from_radio.my_info.my_node_num
138
+ end
139
+ serial_obj[:metadata] = from_radio.metadata.to_h if serial_obj && from_radio.metadata
140
+
141
+ if from_radio.log_record
142
+ msg = from_radio.log_record.message.to_s
143
+ @rx_mutex.synchronize { @console_data << "#{msg}\n" } unless msg.empty?
144
+ end
145
+
146
+ from_radio
147
+ rescue Google::Protobuf::ParseError => e
148
+ warn "Meshtastic::SerialInterface: failed to decode FromRadio (#{e.message})"
149
+ nil
150
+ end
151
+
152
+ # ---- public API ----------------------------------------------------------
153
+
48
154
  # Supported Method Parameters::
49
155
  # Meshtastic::SerialInterface.request(
50
156
  # serial_obj: 'required serial_obj returned from #connect method',
51
- # payload: 'required - array of bytes OR string to write to serial device (e.g. [0x00, 0x41, 0x90, 0x00] OR "\x00\x41\c90\x00\r\n"'
157
+ # payload: 'required - array of bytes OR string to write to serial device'
52
158
  # )
53
-
54
159
  public_class_method def self.request(opts = {})
55
160
  serial_obj = opts[:serial_obj]
56
161
  serial_conn = serial_obj[:serial_conn]
57
162
  payload = opts[:payload]
58
163
 
59
- byte_arr = nil
60
- byte_arr = payload if payload.instance_of?(Array)
61
- byte_arr = payload.chars if payload.instance_of?(String)
62
- raise "ERROR: Invalid payload type: #{payload.class}" if byte_arr.nil?
63
-
64
- byte_arr.each do |byte|
65
- serial_conn.putc(byte)
66
- end
164
+ bytes =
165
+ case payload
166
+ when String then payload.b
167
+ when Array then payload.pack('C*')
168
+ else
169
+ raise "ERROR: Invalid payload type: #{payload.class}"
170
+ end
67
171
 
68
- sleep(0.1)
172
+ serial_conn.write(bytes)
69
173
  serial_conn.flush
174
+ sleep 0.05
175
+ bytes.bytesize
70
176
  rescue StandardError => e
71
177
  disconnect(serial_obj: serial_obj) unless serial_obj.nil?
72
178
  raise e
73
179
  end
74
180
 
181
+ # Supported Method Parameters::
182
+ # Meshtastic::SerialInterface.send_to_radio(
183
+ # serial_obj: 'required - serial_obj returned from #connect method',
184
+ # to_radio: 'required - Meshtastic::ToRadio OR already-serialized String'
185
+ # )
186
+ public_class_method def self.send_to_radio(opts = {})
187
+ serial_obj = opts[:serial_obj]
188
+ raise 'ERROR: serial_obj is required' unless serial_obj
189
+
190
+ to_radio = opts[:to_radio]
191
+ raise 'ERROR: to_radio is required' if to_radio.nil?
192
+
193
+ body =
194
+ case to_radio
195
+ when String
196
+ to_radio.b
197
+ when Meshtastic::ToRadio
198
+ to_radio.to_proto
199
+ else
200
+ raise "ERROR: to_radio must be Meshtastic::ToRadio or String, got #{to_radio.class}"
201
+ end
202
+
203
+ raise "ERROR: ToRadio payload too large (#{body.bytesize} > #{Meshtastic::MAX_TO_FROM_RADIO_SIZE})" if body.bytesize > Meshtastic::MAX_TO_FROM_RADIO_SIZE
204
+
205
+ header = [
206
+ Meshtastic::START1,
207
+ Meshtastic::START2,
208
+ (body.bytesize >> 8) & 0xFF,
209
+ body.bytesize & 0xFF
210
+ ].pack('C*')
211
+
212
+ request(serial_obj: serial_obj, payload: header + body)
213
+ end
214
+
75
215
  # Supported Method Parameters::
76
216
  # serial_obj = Meshtastic::SerialInterface.connect(
77
217
  # block_dev: 'optional - serial block device path (defaults to /dev/ttyUSB0)',
78
218
  # baud: 'optional - (defaults to 115200)',
79
219
  # data_bits: 'optional - (defaults to 8)',
80
220
  # stop_bits: 'optional - (defaults to 1)',
81
- # parity: 'optional - :even|:mark|:odd|:space|:none (defaults to :none)'
221
+ # parity: 'optional - :even|:odd|:none (defaults to :none)',
222
+ # debug_out: 'optional - IO to receive non-protobuf debug console bytes',
223
+ # want_config: 'optional - request full node DB after connect (default: true)'
82
224
  # )
83
-
84
225
  public_class_method def self.connect(opts = {})
85
226
  block_dev = opts[:block_dev] ||= '/dev/ttyUSB0'
86
227
  raise "Invalid block device: #{block_dev}" unless File.exist?(block_dev)
@@ -89,56 +230,64 @@ module Meshtastic
89
230
  data_bits = opts[:data_bits] ||= 8
90
231
  stop_bits = opts[:stop_bits] ||= 1
91
232
  parity = opts[:parity] ||= :none
233
+ debug_out = opts[:debug_out]
234
+ want_config = opts.fetch(:want_config, true)
235
+
236
+ parity_char =
237
+ case parity.to_s.to_sym
238
+ when :even then 'E'
239
+ when :odd then 'O'
240
+ when :none then 'N'
241
+ else
242
+ raise "Invalid parity: #{opts[:parity]}"
243
+ end
92
244
 
93
- case parity.to_s.to_sym
94
- when :even
95
- parity = 'E'
96
- when :odd
97
- parity = 'O'
98
- when :none
99
- parity = 'N'
100
- end
101
- raise "Invalid parity: #{opts[:parity]}" if parity.nil?
245
+ mode = "#{data_bits}#{parity_char}#{stop_bits}"
102
246
 
103
- mode = "#{data_bits}#{parity}#{stop_bits}"
247
+ clear_hupcl(block_dev)
104
248
 
105
- serial_conn = UART.open(
106
- block_dev,
107
- baud,
108
- mode
109
- )
249
+ serial_conn = UART.open(block_dev, baud, mode)
110
250
 
111
- serial_obj = {}
112
- serial_obj[:serial_conn] = serial_conn
113
- serial_obj[:console_thread] = init_stdout_thread(
251
+ serial_obj = {
114
252
  serial_conn: serial_conn,
115
- type: :console
116
- )
117
- serial_obj[:proto_thread] = init_stdout_thread(
253
+ block_dev: block_dev,
254
+ baud: baud,
255
+ my_info: nil,
256
+ my_node_num: nil,
257
+ metadata: nil
258
+ }
259
+
260
+ serial_obj[:rx_thread] = init_rx_thread(
118
261
  serial_conn: serial_conn,
119
- type: :proto
262
+ serial_obj: serial_obj,
263
+ debug_out: debug_out
120
264
  )
121
265
 
266
+ # Wake / resync the device's framing state-machine.
122
267
  wake_up_device(serial_obj: serial_obj)
123
268
 
124
- mui = Meshtastic::MeshInterface.new
125
- mui.start_config
269
+ if want_config
270
+ mui = Meshtastic::MeshInterface.new
271
+ to_radio_bytes = mui.start_config
272
+ send_to_radio(serial_obj: serial_obj, to_radio: to_radio_bytes)
273
+ end
126
274
 
127
275
  serial_obj
128
276
  rescue StandardError => e
129
277
  disconnect(serial_obj: serial_obj) unless serial_obj.nil?
130
278
  raise e
131
279
  end
132
- #
280
+
133
281
  # Supported Method Parameters::
134
282
  # wake_up_device(
135
283
  # serial_obj: 'required - serial_obj returned from #connect method'
136
284
  # )
137
285
  public_class_method def self.wake_up_device(opts = {})
138
286
  serial_obj = opts[:serial_obj]
139
-
140
- start2_byte_arr = [START2].pack('C') * 32
141
- request(serial_obj: serial_obj, payload: start2_byte_arr)
287
+ # START2 * 32 — does not look like a valid header, forces RX state machine resync
288
+ start2_bytes = ([Meshtastic::START2] * 32).pack('C*')
289
+ request(serial_obj: serial_obj, payload: start2_bytes)
290
+ sleep 0.1
142
291
  rescue StandardError => e
143
292
  disconnect(serial_obj: serial_obj) unless serial_obj.nil?
144
293
  raise e
@@ -148,48 +297,79 @@ module Meshtastic
148
297
  # stdout_data = Meshtastic::SerialInterface.dump_stdout_data(
149
298
  # type: 'required - :proto or :console'
150
299
  # )
151
-
152
300
  public_class_method def self.dump_stdout_data(opts = {})
153
301
  type = opts[:type]
154
302
  valid_types = %i[proto console]
155
303
  raise "ERROR: Invalid type: #{type}. Supported types are :proto or :console" unless valid_types.include?(type)
156
304
 
157
- if block_given?
158
- @proto_data.each { |proto_hash| yield proto_hash } if type == :proto
159
- @console_data.join.split("\n").each{ |line| yield line.force_encoding('UTF-8') } if type == :console
160
- else
161
- stdout_data = @proto_data if type == :proto
162
- stdout_data = @console_data.join if type == :console
163
-
164
- stdout_data
305
+ @rx_mutex.synchronize do
306
+ if block_given?
307
+ if type == :proto
308
+ @proto_data.each { |proto_hash| yield proto_hash }
309
+ else
310
+ @console_data.join.split("\n").each { |line| yield line.force_encoding('UTF-8') }
311
+ end
312
+ nil
313
+ else
314
+ type == :proto ? @proto_data.dup : @console_data.join
315
+ end
165
316
  end
166
- rescue StandardError => e
167
- raise e
168
317
  end
169
318
 
170
319
  # Supported Method Parameters::
171
- # console_data = Meshtastic::SerialInterface.flush_data(opts = {})
172
-
173
- public_class_method def self.flush_data(opts = {})
320
+ # Meshtastic::SerialInterface.flush_data(
321
+ # type: 'required - :proto or :console'
322
+ # )
323
+ public_class_method def self.flush_data(opts = {}) # rubocop:disable Naming/PredicateMethod
174
324
  type = opts[:type]
175
325
  valid_types = %i[proto console]
176
326
  raise "ERROR: Invalid type: #{type}. Supported types are :proto or :console" unless valid_types.include?(type)
177
327
 
178
- @console_data.clear if type == :console
179
- @proto_data.clear if type == :proto
180
- rescue StandardError => e
181
- raise e
328
+ @rx_mutex.synchronize do
329
+ @console_data.clear if type == :console
330
+ @proto_data.clear if type == :proto
331
+ end
332
+ true
333
+ end
334
+
335
+ # Drain the FromRadio queue without blocking (returns Array of FromRadio msgs).
336
+ public_class_method def self.drain_from_radio(opts = {})
337
+ max = opts[:max] ||= 256
338
+ msgs = []
339
+ return msgs unless @from_radio_queue
340
+
341
+ max.times do
342
+ msgs << @from_radio_queue.pop(true)
343
+ rescue ThreadError
344
+ break
345
+ end
346
+ msgs
347
+ end
348
+
349
+ # Block until a FromRadio arrives or timeout (seconds). Returns FromRadio or nil.
350
+ public_class_method def self.recv_from_radio(opts = {})
351
+ timeout = opts[:timeout] ||= 5
352
+ raise 'ERROR: RX queue not initialised — call connect first' unless @from_radio_queue
353
+
354
+ if timeout.nil? || timeout.negative?
355
+ @from_radio_queue.pop
356
+ else
357
+ begin
358
+ Timeout.timeout(timeout) { @from_radio_queue.pop }
359
+ rescue Timeout::Error
360
+ nil
361
+ end
362
+ end
182
363
  end
183
364
 
184
365
  # Supported Method Parameters::
185
- # console_data = Meshtastic::SerialInterface.monitor_stdout(
366
+ # Meshtastic::SerialInterface.monitor_stdout(
186
367
  # serial_obj: 'required - serial_obj returned from #connect method',
187
368
  # type: 'required - :proto or :console',
188
369
  # refresh: 'optional - refresh interval (default: 3)',
189
370
  # include: 'optional - comma-delimited string(s) to include in message (default: nil)',
190
371
  # exclude: 'optional - comma-delimited string(s) to exclude in message (default: nil)'
191
372
  # )
192
-
193
373
  public_class_method def self.monitor_stdout(opts = {})
194
374
  serial_obj = opts[:serial_obj]
195
375
  type = opts[:type]
@@ -205,14 +385,14 @@ module Meshtastic
205
385
  include_arr = include.to_s.split(',').map(&:strip)
206
386
 
207
387
  dump_stdout_data(type: type) do |data|
208
- disp = false
209
- disp = true if !exclude_arr.intersect?(data) && (
210
- include_arr.empty? ||
211
- include_arr.all? { |include| data.include?(include) }
212
- )
213
- puts data if disp
214
- flush_data(type: type)
388
+ data_s = data.is_a?(Hash) ? data.inspect : data.to_s
389
+ disp = !exclude_arr.intersect?(data_s) && (
390
+ include_arr.empty? ||
391
+ include_arr.all? { |inc| data_s.include?(inc) }
392
+ )
393
+ puts data_s if disp
215
394
  end
395
+ flush_data(type: type)
216
396
  sleep refresh
217
397
  end
218
398
  rescue Interrupt
@@ -223,26 +403,73 @@ module Meshtastic
223
403
  raise e
224
404
  end
225
405
 
406
+ # Enrich / optionally decrypt a MeshPacket hash (shared by MQTT + serial).
407
+ private_class_method def self.enrich_packet(opts = {})
408
+ message = opts[:message]
409
+ psks = opts[:psks] || {}
410
+ gps_metadata = opts[:gps_metadata] || false
411
+ include_raw = opts[:include_raw] || false
412
+ raw_packet = opts[:raw_packet]
413
+
414
+ message[:node_id_from] = "!#{message[:from].to_i.to_s(16)}"
415
+ message[:node_id_to] = "!#{message[:to].to_i.to_s(16)}"
416
+
417
+ message[:rx_time_utc] = Time.at(message[:rx_time]).utc.to_s if message[:rx_time].is_a?(Integer)
418
+
419
+ message[:public_key] = Base64.strict_encode64(message[:public_key]) if message[:public_key].to_s.length.positive? && !(message[:public_key].ascii_only? && message[:public_key] =~ %r{\A[A-Za-z0-9+/]+=*\z})
420
+
421
+ encrypted_message = message[:encrypted]
422
+ if encrypted_message.to_s.length.positive? && psks.any?
423
+ packet_id = message[:id]
424
+ packet_from = message[:from]
425
+ nonce_packet_id = [packet_id].pack('V').ljust(8, "\x00")
426
+ nonce_from_node = [packet_from].pack('V').ljust(8, "\x00")
427
+ nonce = "#{nonce_packet_id}#{nonce_from_node}"
428
+
429
+ psk = psks[:LongFast] || psks[psks.keys.first]
430
+ dec_psk = Base64.strict_decode64(psk)
431
+
432
+ cipher = OpenSSL::Cipher.new(dec_psk.length == 32 ? 'AES-256-CTR' : 'AES-128-CTR')
433
+ cipher.decrypt
434
+ cipher.key = dec_psk
435
+ cipher.iv = nonce
436
+ decrypted = cipher.update(encrypted_message) + cipher.final
437
+ message[:decoded] = Meshtastic::Data.decode(decrypted).to_h
438
+ message[:encrypted] = :decrypted
439
+ end
440
+
441
+ if message[:decoded]
442
+ payload = message[:decoded][:payload]
443
+ msg_type = message[:decoded][:portnum]
444
+ mui = Meshtastic::MeshInterface.new
445
+ message[:decoded][:payload] = mui.decode_payload(
446
+ payload: payload,
447
+ msg_type: msg_type,
448
+ gps_metadata: gps_metadata
449
+ )
450
+ end
451
+
452
+ message[:raw_packet] = raw_packet if include_raw
453
+ message
454
+ rescue OpenSSL::Cipher::CipherError, ArgumentError, Google::Protobuf::ParseError => e
455
+ message[:decrypted] = e.message
456
+ message
457
+ end
458
+
226
459
  # Supported Method Parameters::
227
460
  # Meshtastic::SerialInterface.subscribe(
228
- # serial_obj: 'required - serial_obj returned from #connect method'
229
- # root_topic: 'optional - root topic (default: msh)',
230
- # region: 'optional - region e.g. 'US/VA', etc (default: US)',
231
- # channel_topic: 'optional - channel ID path e.g. "2/stat/#" (default: "2/e/LongFast/#")',
232
- # psks: 'optional - hash of :channel_id => psk key value pairs (default: { LongFast: "AQ==" })',
233
- # qos: 'optional - quality of service (default: 0)',
234
- # exclude: 'optional - comma-delimited string(s) to exclude in message (default: nil)',
235
- # include: 'optional - comma-delimited string(s) to include on in message (default: nil)',
236
- # gps_metadata: 'optional - include GPS metadata in output (default: false)',
237
- # include_raw: 'optional - include raw packet data in output (default: false)'
461
+ # serial_obj: 'required - serial_obj returned from #connect method',
462
+ # psks: 'optional - hash of :channel_id => psk (default: { LongFast: "AQ==" })',
463
+ # exclude: 'optional - comma-delimited substrings to hide',
464
+ # include: 'optional - comma-delimited substrings required to display',
465
+ # gps_metadata: 'optional - reverse-geocode POSITION payloads (default: false)',
466
+ # include_raw: 'optional - include raw protobuf bytes (default: false)',
467
+ # timeout: 'optional - seconds to block on empty queue per iteration (default: nil = forever)'
238
468
  # )
239
-
469
+ # Yields each decoded FromRadio hash. Without a block, pretty-prints packets.
240
470
  public_class_method def self.subscribe(opts = {})
241
471
  serial_obj = opts[:serial_obj]
242
- root_topic = opts[:root_topic] ||= 'msh'
243
- region = opts[:region] ||= 'US'
244
- channel_topic = opts[:channel_topic] ||= '2/e/LongFast/#'
245
- # TODO: Support Array of PSKs and attempt each until decrypted
472
+ raise 'ERROR: serial_obj is required' unless serial_obj
246
473
 
247
474
  public_psk = '1PG7OiApB1nwvP+rz05pAQ=='
248
475
  psks = opts[:psks] ||= { LongFast: public_psk }
@@ -252,212 +479,186 @@ module Meshtastic
252
479
  mui = Meshtastic::MeshInterface.new
253
480
  psks = mui.get_cipher_keys(psks: psks)
254
481
 
255
- qos = opts[:qos] ||= 0
256
- json = opts[:json] ||= false
257
482
  exclude = opts[:exclude]
258
483
  include = opts[:include]
259
484
  gps_metadata = opts[:gps_metadata] ||= false
260
485
  include_raw = opts[:include_raw] ||= false
261
-
262
- # NOTE: Use MQTT Explorer for topic discovery
263
- full_topic = "#{root_topic}/#{region}/#{channel_topic}"
264
- full_topic = "#{root_topic}/#{region}" if region == '#'
265
- puts "Subscribing to: #{full_topic}"
266
- serial_obj.subscribe(full_topic, qos)
267
-
268
- # MQTT::ProtocolException: No Ping Response received for 23 seconds (MQTT::ProtocolException)
486
+ timeout = opts[:timeout]
269
487
 
270
488
  include_arr = include.to_s.split(',').map(&:strip)
271
489
  exclude_arr = exclude.to_s.split(',').map(&:strip)
272
- serial_obj.get_packet do |packet_bytes|
273
- raw_packet = packet_bytes.to_s if include_raw
274
- raw_topic = packet_bytes.topic ||= ''
275
- raw_payload = packet_bytes.payload ||= ''
276
490
 
277
- begin
278
- disp = false
279
- decoded_payload_hash = {}
280
- message = {}
281
- stdout_message = ''
491
+ puts 'Subscribing to serial FromRadio stream...'
282
492
 
283
- if json
284
- decoded_payload_hash = JSON.parse(raw_payload, symbolize_names: true)
493
+ loop do
494
+ from_radio =
495
+ if timeout
496
+ recv_from_radio(timeout: timeout)
285
497
  else
286
- # decoded_payload = Meshtastic::ToRadio.decode(raw_payload)
287
- decoded_payload = Meshtastic::FromRadio.decode(raw_payload)
288
- decoded_payload_hash = decoded_payload.to_h
498
+ @from_radio_queue.pop
289
499
  end
500
+ next if from_radio.nil?
290
501
 
291
- next unless decoded_payload_hash[:packet].is_a?(Hash)
292
-
293
- message = decoded_payload_hash[:packet] if decoded_payload_hash.keys.include?(:packet)
294
- message[:topic] = raw_topic
295
- message[:node_id_from] = "!#{message[:from].to_i.to_s(16)}"
296
- message[:node_id_to] = "!#{message[:to].to_i.to_s(16)}"
297
- if message.keys.include?(:rx_time)
298
- rx_time_int = message[:rx_time]
299
- if rx_time_int.is_a?(Integer)
300
- rx_time_utc = Time.at(rx_time_int).utc.to_s
301
- message[:rx_time_utc] = rx_time_utc
302
- end
303
- end
304
-
305
- if message.keys.include?(:public_key)
306
- raw_public_key = message[:public_key]
307
- message[:public_key] = Base64.strict_encode64(raw_public_key)
308
- end
502
+ begin
503
+ decoded_payload_hash = from_radio.to_h
504
+ raw_packet = from_radio.to_proto if include_raw
309
505
 
310
- # If encrypted_message is not nil, then decrypt
311
- # the message prior to decoding.
312
- encrypted_message = message[:encrypted]
313
- if encrypted_message.to_s.length.positive? &&
314
- message[:topic]
315
-
316
- # if message[:pki_encrypted]
317
- # # TODO: Display Decrypted PKI Message
318
- # public_key = message[:public_key]
319
- # dec_public_key = Base64.strict_decode64(public_key)
320
- # else
321
- packet_id = message[:id]
322
- packet_from = message[:from]
323
-
324
- nonce_packet_id = [packet_id].pack('V').ljust(8, "\x00")
325
- nonce_from_node = [packet_from].pack('V').ljust(8, "\x00")
326
- nonce = "#{nonce_packet_id}#{nonce_from_node}"
327
-
328
- psk = psks[:LongFast]
329
- target_channel = message[:topic].split('/')[-2].to_sym
330
- psk = psks[target_channel] if psks.keys.include?(target_channel)
331
- dec_psk = Base64.strict_decode64(psk)
332
-
333
- cipher = OpenSSL::Cipher.new('AES-128-CTR')
334
- cipher = OpenSSL::Cipher.new('AES-256-CTR') if dec_psk.length == 32
335
- cipher.decrypt
336
- cipher.key = dec_psk
337
- cipher.iv = nonce
338
-
339
- decrypted = cipher.update(encrypted_message) + cipher.final
340
- # end
341
- message[:decoded] = Meshtastic::Data.decode(decrypted).to_h
342
- message[:encrypted] = :decrypted
343
- end
506
+ message = {}
507
+ stdout_message = ''
344
508
 
345
- if message[:decoded]
346
- # payload = Meshtastic::Data.decode(message[:decoded][:payload]).to_h
347
- payload = message[:decoded][:payload]
348
- msg_type = message[:decoded][:portnum]
349
- mui = Meshtastic::MeshInterface.new
350
- message[:decoded][:payload] = mui.decode_payload(
351
- payload: payload,
352
- msg_type: msg_type,
353
- gps_metadata: gps_metadata
509
+ if decoded_payload_hash[:packet].is_a?(Hash)
510
+ message = enrich_packet(
511
+ message: decoded_payload_hash[:packet],
512
+ psks: psks,
513
+ gps_metadata: gps_metadata,
514
+ include_raw: include_raw,
515
+ raw_packet: raw_packet
354
516
  )
517
+ decoded_payload_hash[:packet] = message
355
518
  end
356
519
 
357
- message[:raw_packet] = raw_packet if include_raw
358
- decoded_payload_hash[:packet] = message
359
520
  unless block_given?
360
- message[:stdout] = 'pretty'
521
+ message[:stdout] = 'pretty' if message.is_a?(Hash)
361
522
  stdout_message = JSON.pretty_generate(decoded_payload_hash)
362
523
  end
363
524
  rescue Encoding::CompatibilityError,
364
525
  Google::Protobuf::ParseError,
365
526
  JSON::GeneratorError,
366
527
  ArgumentError => e
367
-
368
- unless e.is_a?(Encoding::CompatibilityError)
369
- message[:decrypted] = e.message if e.message.include?('key must be')
370
- message[:decrypted] = 'unable to decrypt - psk?' if e.message.include?('occurred during parsing')
371
- decoded_payload_hash[:packet] = message
372
- unless block_given?
373
- puts "WARNING: #{e.inspect} - MSG IS >>>"
374
- # puts e.backtrace
375
- message[:stdout] = 'inspect'
376
- stdout_message = decoded_payload_hash.inspect
377
- end
528
+ message[:decrypted] = e.message if message.is_a?(Hash)
529
+ decoded_payload_hash[:packet] = message if message.is_a?(Hash)
530
+ unless block_given?
531
+ message[:stdout] = 'inspect' if message.is_a?(Hash)
532
+ stdout_message = decoded_payload_hash.inspect
378
533
  end
379
-
380
- next
381
534
  ensure
382
- include_arr = [message[:id].to_s] if include_arr.empty?
383
- if message.is_a?(Hash)
384
- flat_message = message.values.join(' ')
385
-
386
- disp = true if !exclude_arr.intersect?(flat_message) && (
387
- include_arr.first == message[:id] ||
388
- include_arr.all? { |include| flat_message.include?(include) }
389
- )
390
-
391
- if disp
392
- if block_given?
393
- yield decoded_payload_hash
394
- else
395
- puts "\n"
396
- puts '-' * 80
397
- puts 'MSG:'
398
- puts stdout_message
399
- puts '-' * 80
400
- puts "\n\n\n"
401
- end
402
- # else
403
- # print '.'
535
+ flat_source = decoded_payload_hash.is_a?(Hash) ? decoded_payload_hash : {}
536
+ flat_message = flat_source.values.join(' ')
537
+ flat_message = "#{flat_message} #{message.values.join(' ')}" if message.is_a?(Hash)
538
+
539
+ disp = !exclude_arr.intersect?(flat_message) &&
540
+ include_arr.all? { |inc| flat_message.include?(inc) }
541
+
542
+ if disp
543
+ if block_given?
544
+ yield decoded_payload_hash
545
+ else
546
+ puts "\n"
547
+ puts '-' * 80
548
+ puts 'MSG:'
549
+ puts stdout_message
550
+ puts '-' * 80
551
+ puts "\n\n\n"
404
552
  end
405
553
  end
406
554
  end
407
555
  end
408
556
  rescue Interrupt
409
557
  puts "\nCTRL+C detected. Exiting..."
410
- serial_obj = disconnect(serial_obj: serial_obj) unless serial_obj.nil?
558
+ disconnect(serial_obj: serial_obj) unless serial_obj.nil?
411
559
  rescue StandardError => e
412
- serial_obj = disconnect(serial_obj: serial_obj) unless serial_obj.nil?
560
+ disconnect(serial_obj: serial_obj) unless serial_obj.nil?
413
561
  raise e
414
562
  end
415
563
 
416
564
  # Supported Method Parameters::
417
565
  # Meshtastic::SerialInterface.send_text(
418
566
  # serial_obj: 'required - serial_obj returned from #connect method',
419
- # from: 'required - From ID (String or Integer) (Default: "!00000b0b")',
567
+ # from: 'optional - From ID (Default: local my_node_num or "!00000b0b")',
420
568
  # to: 'optional - Destination ID (Default: "!ffffffff")',
421
- # topic: 'optional - topic to publish to (Default: "msh/US/2/e/LongFast/1")',
422
- # channel: 'optional - channel (Default: 6)',
569
+ # channel: 'optional - channel index (Default: 0)',
423
570
  # text: 'optional - Text Message (Default: SYN)',
424
571
  # want_ack: 'optional - Want Acknowledgement (Default: false)',
425
572
  # want_response: 'optional - Want Response (Default: false)',
426
573
  # hop_limit: 'optional - Hop Limit (Default: 3)',
427
- # on_response: 'optional - Callback on Response',
428
- # psks: 'optional - hash of :channel_id => psk key value pairs (default: { LongFast: "AQ==" })'
574
+ # psks: 'optional - ignored for serial (device owns channel crypto)'
429
575
  # )
430
576
  public_class_method def self.send_text(opts = {})
431
- # serial_obj = opts[:serial_obj]
432
- # topic = opts[:topic] ||= 'msh/US/2/e/LongFast/#'
577
+ serial_obj = opts[:serial_obj]
578
+ raise 'ERROR: serial_obj is required' unless serial_obj
579
+
580
+ opts = opts.dup
433
581
  opts[:via] = :radio
582
+ opts[:channel] ||= 0
583
+
584
+ if opts[:from].nil?
585
+ opts[:from] =
586
+ if serial_obj[:my_node_num]
587
+ "!#{serial_obj[:my_node_num].to_s(16)}"
588
+ else
589
+ '!00000b0b'
590
+ end
591
+ end
592
+
593
+ # Device performs channel encryption for serial ToRadio packets.
594
+ # Pass empty psks so MeshInterface leaves the payload in :decoded form.
595
+ opts[:psks] = nil
434
596
 
435
- # TODO: Implement chunked message to deal with large messages
436
597
  mui = Meshtastic::MeshInterface.new
437
- mui.send_text(opts)
598
+ protobuf = mui.send_text(opts)
599
+ send_to_radio(serial_obj: serial_obj, to_radio: protobuf)
600
+ rescue StandardError => e
601
+ disconnect(serial_obj: serial_obj) unless serial_obj.nil?
602
+ raise e
603
+ end
604
+
605
+ # Supported Method Parameters::
606
+ # Meshtastic::SerialInterface.send_data(
607
+ # serial_obj: 'required - serial_obj returned from #connect method',
608
+ # ...same kwargs as MeshInterface#send_data (via forced to :radio)
609
+ # )
610
+ public_class_method def self.send_data(opts = {})
611
+ serial_obj = opts[:serial_obj]
612
+ raise 'ERROR: serial_obj is required' unless serial_obj
613
+
614
+ opts = opts.dup
615
+ opts[:via] = :radio
616
+ opts[:channel] ||= 0
617
+ opts[:psks] = nil
618
+ opts[:from] = "!#{serial_obj[:my_node_num].to_s(16)}" if opts[:from].nil? && serial_obj[:my_node_num]
438
619
 
439
- # TODO: serial equivalent of publish
440
- # serial_obj.publish(topic, protobuf_text)
620
+ mui = Meshtastic::MeshInterface.new
621
+ protobuf = mui.send_data(opts)
622
+ send_to_radio(serial_obj: serial_obj, to_radio: protobuf)
441
623
  rescue StandardError => e
442
- serial_obj = disconnect(serial_obj: serial_obj) unless serial_obj.nil?
624
+ disconnect(serial_obj: serial_obj) unless serial_obj.nil?
443
625
  raise e
444
626
  end
445
627
 
446
628
  # Supported Method Parameters::
447
- # serial_obj = Meshtastic.disconnect(
629
+ # serial_obj = Meshtastic::SerialInterface.disconnect(
448
630
  # serial_obj: 'required - serial_obj returned from #connect method'
449
631
  # )
450
632
  public_class_method def self.disconnect(opts = {})
451
633
  serial_obj = opts[:serial_obj]
634
+ return nil unless serial_obj
452
635
 
453
- if serial_obj
454
- console_thread = serial_obj[:console_thread]
455
- proto_thread = serial_obj[:proto_thread]
456
- serial_conn = serial_obj[:serial_conn]
636
+ @want_exit = true
457
637
 
458
- console_thread&.terminate
459
- proto_thread&.terminate
638
+ # Ask device to release the link (best-effort).
639
+ begin
640
+ if serial_obj[:serial_conn] && !serial_obj[:serial_conn].closed?
641
+ to_radio = Meshtastic::ToRadio.new
642
+ to_radio.disconnect = true
643
+ send_to_radio(serial_obj: serial_obj, to_radio: to_radio)
644
+ sleep 0.05
645
+ end
646
+ rescue StandardError
647
+ # ignore during teardown
648
+ end
649
+
650
+ rx_thread = serial_obj[:rx_thread]
651
+ serial_conn = serial_obj[:serial_conn]
652
+
653
+ begin
460
654
  serial_conn&.close
655
+ rescue StandardError
656
+ nil
657
+ end
658
+
659
+ if rx_thread&.alive? && rx_thread != Thread.current
660
+ rx_thread.join(1)
661
+ rx_thread.kill if rx_thread.alive?
461
662
  end
462
663
 
463
664
  nil
@@ -478,14 +679,13 @@ module Meshtastic
478
679
  public_class_method def self.help
479
680
  puts "USAGE:
480
681
  serial_obj = #{self}.connect(
481
- host: 'optional - mqtt host (default: mqtt.meshtastic.org)',
482
- port: 'optional - mqtt port (defaults: 1883)',
483
- tls: 'optional - use TLS (default: false)',
484
- username: 'optional - mqtt username (default: meshdev)',
485
- password: 'optional - (default: large4cats)',
486
- client_id: 'optional - client ID (default: random 4-byte hex string)',
487
- keep_alive: 'optional - keep alive interval (default: 15)',
488
- ack_timeout: 'optional - acknowledgement timeout (default: 30)'
682
+ block_dev: 'optional - serial block device path (defaults to /dev/ttyUSB0)',
683
+ baud: 'optional - (defaults to 115200)',
684
+ data_bits: 'optional - (defaults to 8)',
685
+ stop_bits: 'optional - (defaults to 1)',
686
+ parity: 'optional - :even|:odd|:none (defaults to :none)',
687
+ debug_out: 'optional - IO receiving non-protobuf debug console bytes',
688
+ want_config: 'optional - request full node DB after connect (default: true)'
489
689
  )
490
690
 
491
691
  #{self}.wake_up_device(
@@ -494,53 +694,70 @@ module Meshtastic
494
694
 
495
695
  #{self}.request(
496
696
  serial_obj: 'required serial_obj returned from #connect method',
497
- payload: 'required - array of bytes OR string to write to serial device (e.g. [0x00, 0x41, 0x90, 0x00] OR \"\\x00\\x41\\c90\\x00\\r\\n\"'
697
+ payload: 'required - array of bytes OR string to write to serial device'
498
698
  )
499
699
 
700
+ #{self}.send_to_radio(
701
+ serial_obj: 'required - serial_obj returned from #connect method',
702
+ to_radio: 'required - Meshtastic::ToRadio OR serialized String'
703
+ )
704
+
705
+ from_radio = #{self}.recv_from_radio(
706
+ timeout: 'optional - seconds (default: 5; nil = block forever)'
707
+ )
708
+
709
+ msgs = #{self}.drain_from_radio(max: 256)
710
+
500
711
  stdout_data = #{self}.dump_stdout_data(
501
712
  type: 'required - :proto or :console'
502
713
  )
503
714
 
504
715
  #{self}.flush_data(
505
- type: 'optional - :console or :proto (default: nil)'
716
+ type: 'required - :console or :proto'
506
717
  )
507
718
 
508
719
  #{self}.monitor_stdout(
720
+ serial_obj: 'required - serial_obj returned from #connect method',
509
721
  type: 'required - :proto or :console',
510
722
  refresh: 'optional - refresh interval (default: 3)',
511
- include: 'optional - comma-delimited string(s) to include in message (default: nil)',
512
- exclude: 'optional - comma-delimited string(s) to exclude in message (default: nil)',
723
+ include: 'optional - comma-delimited string(s) to include in message',
724
+ exclude: 'optional - comma-delimited string(s) to exclude in message'
513
725
  )
514
726
 
515
727
  #{self}.subscribe(
516
- serial_obj: 'required - serial_obj object returned from #connect method',
517
- root_topic: 'optional - root topic (default: msh)',
518
- region: 'optional - region e.g. 'US/VA', etc (default: US)',
519
- channel_topic: 'optional - channel ID path e.g. '2/stat/#' (default: '2/e/LongFast/#')',
520
- psks: 'optional - hash of :channel_id => psk key value pairs (default: { LongFast: 'AQ==' })',
521
- qos: 'optional - quality of service (default: 0)',
522
- json: 'optional - JSON output (default: false)',
523
- exclude: 'optional - comma-delimited string(s) to exclude in message (default: nil)',
524
- include: 'optional - comma-delimited string(s) to include on in message (default: nil)',
525
- gps_metadata: 'optional - include GPS metadata in output (default: false)'
728
+ serial_obj: 'required - serial_obj returned from #connect method',
729
+ psks: 'optional - hash of :channel_id => psk (default: { LongFast: \"AQ==\" })',
730
+ exclude: 'optional - comma-delimited string(s) to exclude',
731
+ include: 'optional - comma-delimited string(s) to include',
732
+ gps_metadata: 'optional - include GPS metadata (default: false)',
733
+ include_raw: 'optional - include raw packet bytes (default: false)',
734
+ timeout: 'optional - seconds per pop (default: nil = forever)'
526
735
  )
527
736
 
528
737
  #{self}.send_text(
529
738
  serial_obj: 'required - serial_obj returned from #connect method',
530
- from: 'required - From ID (String or Integer) (Default: \"!00000b0b\")',
739
+ from: 'optional - From ID (Default: local my_node_num or \"!00000b0b\")',
531
740
  to: 'optional - Destination ID (Default: \"!ffffffff\")',
532
- topic: 'optional - topic to publish to (default: 'msh/US/2/e/LongFast/1')',
533
- channel: 'optional - channel (Default: 6)',
741
+ channel: 'optional - channel index (Default: 0)',
534
742
  text: 'optional - Text Message (Default: SYN)',
535
743
  want_ack: 'optional - Want Acknowledgement (Default: false)',
536
744
  want_response: 'optional - Want Response (Default: false)',
745
+ hop_limit: 'optional - Hop Limit (Default: 3)'
746
+ )
747
+
748
+ #{self}.send_data(
749
+ serial_obj: 'required - serial_obj returned from #connect method',
750
+ from: 'optional - From ID',
751
+ to: 'optional - Destination ID (Default: \"!ffffffff\")',
752
+ channel: 'optional - channel index (Default: 0)',
753
+ data: 'required - Meshtastic::Data',
754
+ want_ack: 'optional - Want Acknowledgement (Default: false)',
537
755
  hop_limit: 'optional - Hop Limit (Default: 3)',
538
- on_response: 'optional - Callback on Response',
539
- psks: 'optional - hash of :channel => psk key value pairs (default: { LongFast: 'AQ==' })'
756
+ port_num: 'optional - PortNum (Default: PRIVATE_APP)'
540
757
  )
541
758
 
542
759
  serial_obj = #{self}.disconnect(
543
- serial_obj: 'required - serial_obj object returned from #connect method'
760
+ serial_obj: 'required - serial_obj returned from #connect method'
544
761
  )
545
762
 
546
763
  #{self}.authors