meshtastic 0.0.176 → 0.0.177

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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'timeout'
5
+
6
+ # Meshtastic client API over Bluetooth Low Energy, using Linux BlueZ and Ruby D-Bus.
7
+ module Meshtastic
8
+ module Bluetooth
9
+ autoload :BlueZ, 'meshtastic/bluetooth/bluez'
10
+
11
+ def self.scan(opts = {})
12
+ BlueZ.scan(adapter: opts.fetch(:adapter, 'hci0'), timeout: opts.fetch(:timeout, 5))
13
+ end
14
+
15
+ # Connect to an already paired BLE address (not a mesh node ID).
16
+ def self.connect(opts = {})
17
+ connection = BlueZ.new(address: opts[:address], adapter: opts.fetch(:adapter, 'hci0'), timeout: opts.fetch(:timeout, 15))
18
+ connection.connect
19
+ bluetooth_obj = {
20
+ bluetooth_conn: connection, address: opts[:address], tx_mutex: Mutex.new,
21
+ rx_mutex: Mutex.new, from_radio_queue: Queue.new, config_queue: Queue.new,
22
+ proto_data: [], console_data: []
23
+ }
24
+ bluetooth_obj[:rx_thread] = start_reader(bluetooth_obj)
25
+ if opts.fetch(:want_config, true)
26
+ mesh = Meshtastic::MeshInterface.new
27
+ bytes = mesh.start_config
28
+ bluetooth_obj[:config_id] = mesh.config_id
29
+ send_to_radio(bluetooth_obj: bluetooth_obj, to_radio: bytes)
30
+ end
31
+ @last_bluetooth_obj = bluetooth_obj
32
+ bluetooth_obj
33
+ rescue StandardError
34
+ bluetooth_obj ? disconnect(bluetooth_obj: bluetooth_obj) : connection&.close
35
+ raise
36
+ end
37
+
38
+ private_class_method def self.start_reader(handle)
39
+ Thread.new do
40
+ until handle[:closing]
41
+ bytes = handle[:bluetooth_conn].read
42
+ if bytes.empty?
43
+ sleep 0.1
44
+ next
45
+ end
46
+ receive_bytes(handle, bytes)
47
+ end
48
+ rescue StandardError => e
49
+ handle[:rx_error] = IOError.new("Bluetooth receive failed: #{e.message}") unless handle[:closing]
50
+ ensure
51
+ handle[:from_radio_queue].close
52
+ handle[:config_queue].close
53
+ end
54
+ end
55
+
56
+ private_class_method def self.receive_bytes(handle, bytes)
57
+ message = Meshtastic::FromRadio.decode(bytes)
58
+ if message.my_info
59
+ handle[:my_info] = message.my_info.to_h
60
+ handle[:my_node_num] = message.my_info.my_node_num
61
+ end
62
+ handle[:metadata] = message.metadata.to_h if message.metadata
63
+ handle[:rx_mutex].synchronize do
64
+ handle[:proto_data] << message.to_h
65
+ handle[:console_data] << "#{message.log_record.message}\n" if message.log_record
66
+ end
67
+ handle[:from_radio_queue] << message
68
+ if message.payload_variant == :config_complete_id && message.config_complete_id == handle[:config_id]
69
+ handle[:config_complete] = true
70
+ handle[:config_queue].close
71
+ end
72
+ rescue Google::Protobuf::ParseError => e
73
+ warn "Meshtastic::Bluetooth: failed to decode FromRadio (#{e.message})"
74
+ end
75
+
76
+ private_class_method def self.handle_for(opts)
77
+ handle = opts[:bluetooth_obj] || @last_bluetooth_obj
78
+ raise ArgumentError, 'bluetooth_obj is required; call connect first' unless handle
79
+
80
+ handle
81
+ end
82
+
83
+ def self.wait_for_config(opts = {})
84
+ handle = handle_for(opts)
85
+ raise ArgumentError, 'connect with want_config: true first' unless handle[:config_id]
86
+
87
+ handle[:config_queue].pop(timeout: opts.fetch(:timeout, 10))
88
+ raise handle[:rx_error] if handle[:rx_error]
89
+ raise IOError, 'Bluetooth connection closed' if handle[:closing]
90
+ raise Timeout::Error, "No configuration response from #{handle[:address]}" unless handle[:config_complete]
91
+
92
+ handle
93
+ end
94
+
95
+ def self.recv_from_radio(opts = {})
96
+ handle = handle_for(opts)
97
+ timeout = opts.fetch(:timeout, 5)
98
+ timeout = nil if timeout&.negative?
99
+ message = handle[:from_radio_queue].pop(timeout: timeout)
100
+ raise handle[:rx_error] if message.nil? && handle[:rx_error]
101
+
102
+ message
103
+ end
104
+
105
+ def self.drain_from_radio(opts = {})
106
+ handle = handle_for(opts)
107
+ messages = []
108
+ opts.fetch(:max, 256).times do
109
+ message = recv_from_radio(bluetooth_obj: handle, timeout: 0)
110
+ break unless message
111
+
112
+ messages << message
113
+ end
114
+ messages
115
+ end
116
+
117
+ def self.dump_stdout_data(opts = {}, &)
118
+ Meshtastic::Serial.dump_stdout_data(opts.merge(serial_obj: handle_for(opts)), &)
119
+ end
120
+
121
+ def self.flush_data(opts = {})
122
+ Meshtastic::Serial.flush_data(opts.merge(serial_obj: handle_for(opts)))
123
+ end
124
+
125
+ # Yield the same enriched FromRadio hashes as Serial, without opening a UART.
126
+ def self.subscribe(opts = {})
127
+ handle = handle_for(opts)
128
+ psks = opts.fetch(:psks, { LongFast: 'AQ==' }).dup
129
+ raise ArgumentError, 'psks must be a hash' unless psks.is_a?(Hash)
130
+
131
+ psks[:LongFast] = '1PG7OiApB1nwvP+rz05pAQ==' if psks[:LongFast] == 'AQ=='
132
+ psks = Meshtastic::MeshInterface.new.get_cipher_keys(psks: psks)
133
+ includes = opts[:include].to_s.split(',').map(&:strip)
134
+ excludes = opts[:exclude].to_s.split(',').map(&:strip)
135
+ loop do
136
+ message = recv_from_radio(bluetooth_obj: handle, timeout: opts[:timeout])
137
+ break if message.nil? && handle[:from_radio_queue].closed?
138
+ next unless message
139
+
140
+ decoded = message.to_h
141
+ if decoded[:packet]
142
+ # Share the existing pure packet decoder; BLE never uses Serial's IO methods.
143
+ decoded[:packet] = Meshtastic::Serial.send(:enrich_packet,
144
+ message: decoded[:packet], psks: psks,
145
+ gps_metadata: opts[:gps_metadata], include_raw: opts[:include_raw],
146
+ raw_packet: opts[:include_raw] ? message.to_proto : nil)
147
+ end
148
+ source = decoded.inspect
149
+ next unless includes.all? { |term| source.include?(term) } && excludes.none? { |term| source.include?(term) }
150
+
151
+ if block_given?
152
+ yield decoded
153
+ else
154
+ begin
155
+ puts JSON.pretty_generate(decoded)
156
+ rescue JSON::GeneratorError
157
+ puts decoded.inspect
158
+ end
159
+ end
160
+ end
161
+ rescue Interrupt
162
+ disconnect(bluetooth_obj: handle)
163
+ rescue StandardError
164
+ disconnect(bluetooth_obj: handle)
165
+ raise
166
+ end
167
+
168
+ # Write one complete serialized ToRadio to GATT. BLE does not use UART headers.
169
+ def self.send_to_radio(opts = {})
170
+ handle = opts[:bluetooth_obj]
171
+ raise ArgumentError, 'bluetooth_obj is required' unless handle
172
+ raise IOError, 'Bluetooth connection closed' if handle[:closing]
173
+
174
+ message = opts[:to_radio]
175
+ body = case message
176
+ when Meshtastic::ToRadio then message.to_proto
177
+ when String then message.b
178
+ else raise ArgumentError, 'to_radio must be Meshtastic::ToRadio or a serialized String'
179
+ end
180
+ raise ArgumentError, 'ToRadio payload exceeds 512 bytes' if body.bytesize > Meshtastic::MAX_TO_FROM_RADIO_SIZE
181
+
182
+ begin
183
+ handle[:tx_mutex].synchronize { handle[:bluetooth_conn].write(body) }
184
+ rescue StandardError
185
+ disconnect(bluetooth_obj: handle)
186
+ raise
187
+ end
188
+ end
189
+
190
+ def self.send_text(opts = {})
191
+ handle = opts[:bluetooth_obj]
192
+ raise ArgumentError, 'bluetooth_obj is required' unless handle
193
+
194
+ text = opts.fetch(:text, 'SYN').to_s
195
+ max_len = Meshtastic::Constants::DATA_PAYLOAD_LEN
196
+ raise ArgumentError, "Text Length > #{max_len} Bytes" if text.bytesize > max_len
197
+
198
+ args = opts.merge(text: text, via: :radio, psks: nil, channel: opts.fetch(:channel, 0), from: opts[:from] || handle[:my_node_num] || 0)
199
+ send_to_radio(bluetooth_obj: handle, to_radio: Meshtastic::MeshInterface.new.send_text(args))
200
+ end
201
+
202
+ def self.send_data(opts = {})
203
+ handle = opts[:bluetooth_obj]
204
+ raise ArgumentError, 'bluetooth_obj is required' unless handle
205
+ raise ArgumentError, 'data must be Meshtastic::Data' unless opts[:data].is_a?(Meshtastic::Data)
206
+
207
+ args = opts.merge(via: :radio, psks: nil, channel: opts.fetch(:channel, 0), from: opts[:from] || handle[:my_node_num] || 0)
208
+ send_to_radio(bluetooth_obj: handle, to_radio: Meshtastic::MeshInterface.new.send_data(args))
209
+ end
210
+
211
+ def self.disconnect(opts = {})
212
+ handle = opts[:bluetooth_obj]
213
+ return unless handle
214
+ return if handle[:closing]
215
+
216
+ handle[:closing] = true
217
+ handle[:from_radio_queue].close
218
+ handle[:config_queue].close
219
+ begin
220
+ handle[:tx_mutex].synchronize do
221
+ handle[:bluetooth_conn].write(Meshtastic::ToRadio.new(disconnect: true).to_proto)
222
+ end
223
+ rescue StandardError
224
+ # A lost BLE link cannot receive a disconnect request.
225
+ nil
226
+ ensure
227
+ handle[:bluetooth_conn].close
228
+ reader = handle[:rx_thread]
229
+ reader.join(1) if reader && reader != Thread.current
230
+ end
231
+ nil
232
+ end
233
+
234
+ def self.authors
235
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n "
236
+ end
237
+
238
+ def self.help
239
+ puts "Send and receive Meshtastic messages over Bluetooth Low Energy (Linux BlueZ).
240
+ BLE writes unframed ToRadio protobufs (no UART START1/START2 header). Pair first with bluetoothctl.
241
+
242
+ USAGE:
243
+ devices = #{self}.scan(
244
+ adapter: 'optional - BlueZ adapter (default: hci0)',
245
+ timeout: 'optional - discovery seconds (default: 5)'
246
+ )
247
+
248
+ bluetooth_obj = #{self}.connect(
249
+ address: 'required - BLE address (AA:BB:CC:DD:EE:FF), not a mesh node ID',
250
+ adapter: 'optional - BlueZ adapter (default: hci0)',
251
+ timeout: 'optional - D-Bus/connect seconds (default: 15)',
252
+ want_config: 'optional - request full node DB after connect (default: true)'
253
+ )
254
+
255
+ #{self}.wait_for_config(
256
+ bluetooth_obj: 'required - bluetooth_obj connected with want_config: true',
257
+ timeout: 'optional - seconds to await configuration (default: 10)'
258
+ )
259
+
260
+ #{self}.send_to_radio(
261
+ bluetooth_obj: 'required - bluetooth_obj returned from #connect',
262
+ to_radio: 'required - Meshtastic::ToRadio OR serialized String'
263
+ )
264
+
265
+ from_radio = #{self}.recv_from_radio(
266
+ bluetooth_obj: 'optional - bluetooth_obj (default: most recently opened connection)',
267
+ timeout: 'optional - seconds (default: 5; 0 = poll; nil = block forever)'
268
+ )
269
+
270
+ msgs = #{self}.drain_from_radio(bluetooth_obj: bluetooth_obj, max: 256)
271
+
272
+ #{self}.subscribe(
273
+ bluetooth_obj: 'required - bluetooth_obj returned from #connect',
274
+ include: 'optional - comma-delimited string(s) to include',
275
+ exclude: 'optional - comma-delimited string(s) to exclude'
276
+ )
277
+
278
+ #{self}.send_text(
279
+ bluetooth_obj: 'required - bluetooth_obj returned from #connect',
280
+ to: 'optional - Destination ID (Default: \"!ffffffff\")',
281
+ channel: 'optional - channel index (Default: 0)',
282
+ text: 'optional - Text Message (Default: SYN)',
283
+ want_ack: 'optional - Want Acknowledgement (Default: false)'
284
+ )
285
+
286
+ #{self}.send_data(
287
+ bluetooth_obj: 'required - bluetooth_obj returned from #connect',
288
+ data: 'required - Meshtastic::Data',
289
+ to: 'optional - Destination ID (Default: \"!ffffffff\")',
290
+ channel: 'optional - channel index (Default: 0)'
291
+ )
292
+
293
+ #{self}.disconnect(bluetooth_obj: bluetooth_obj)
294
+
295
+ #{self}.authors
296
+ "
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: meshtastic/lorawan_bridge.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x1fmeshtastic/lorawan_bridge.proto\x12\nmeshtastic\"\xfc\x07\n\rLoRaWANBridge\x12\x32\n\x06uplink\x18\x01 \x01(\x0b\x32 .meshtastic.LoRaWANBridge.UplinkH\x00\x12\x36\n\x08\x64ownlink\x18\x02 \x01(\x0b\x32\".meshtastic.LoRaWANBridge.DownlinkH\x00\x12\x37\n\ttx_result\x18\x03 \x01(\x0b\x32\".meshtastic.LoRaWANBridge.TxResultH\x00\x12\x37\n\x05\x63hunk\x18\x04 \x01(\x0b\x32&.meshtastic.LoRaWANBridge.PayloadChunkH\x00\x1a\xaf\x01\n\x06Uplink\x12\x0f\n\x07\x66req_hz\x18\x01 \x01(\x07\x12\x0c\n\x04tmst\x18\x02 \x01(\x07\x12\x10\n\x08rssi_x10\x18\x03 \x01(\x11\x12\x0f\n\x07snr_x10\x18\x04 \x01(\x11\x12\x14\n\x0cradio_params\x18\x05 \x01(\r\x12\x13\n\x0b\x66sk_bitrate\x18\t \x01(\r\x12\x0f\n\x07payload\x18\x06 \x01(\x0c\x12\x12\n\npayload_id\x18\x07 \x01(\r\x12\x13\n\x0b\x63hunk_count\x18\x08 \x01(\r\x1a\xef\x01\n\x08\x44ownlink\x12\x0f\n\x07\x66req_hz\x18\x01 \x01(\x07\x12\x0c\n\x04tmst\x18\x02 \x01(\x07\x12\x14\n\x0cradio_params\x18\x03 \x01(\r\x12\x11\n\tpower_dbm\x18\x04 \x01(\r\x12\x11\n\timmediate\x18\x05 \x01(\x08\x12\x17\n\x0finvert_polarity\x18\x06 \x01(\x08\x12\x0e\n\x06no_crc\x18\x07 \x01(\x08\x12\x11\n\tmay_defer\x18\x08 \x01(\x08\x12\x12\n\nrequest_id\x18\x0c \x01(\r\x12\x0f\n\x07payload\x18\t \x01(\x0c\x12\x12\n\npayload_id\x18\n \x01(\r\x12\x13\n\x0b\x63hunk_count\x18\x0b \x01(\r\x1aN\n\x0cPayloadChunk\x12\x12\n\npayload_id\x18\x01 \x01(\r\x12\x13\n\x0b\x63hunk_index\x18\x02 \x01(\r\x12\x15\n\rpayload_chunk\x18\x03 \x01(\x0c\x1a\x8d\x02\n\x08TxResult\x12\x0c\n\x04tmst\x18\x01 \x01(\x07\x12\x39\n\x06status\x18\x02 \x01(\x0e\x32).meshtastic.LoRaWANBridge.TxResult.Status\x12\x12\n\nrequest_id\x18\x03 \x01(\r\"\xa3\x01\n\x06Status\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08TOO_LATE\x10\x01\x12\r\n\tTOO_EARLY\x10\x02\x12\x14\n\x10\x43OLLISION_PACKET\x10\x03\x12\x14\n\x10\x43OLLISION_BEACON\x10\x04\x12\x0b\n\x07TX_FREQ\x10\x05\x12\x0c\n\x08TX_POWER\x10\x06\x12\x10\n\x0cGPS_UNLOCKED\x10\x07\x12\x0c\n\x08\x44\x45\x46\x45RRED\x10\x08\x12\x0b\n\x07\x44ROPPED\x10\tB\t\n\x07variantBi\n\x14org.meshtastic.protoB\x13LoRaWANBridgeProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3"
9
+
10
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module Meshtastic
14
+ LoRaWANBridge = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("meshtastic.LoRaWANBridge").msgclass
15
+ LoRaWANBridge::Uplink = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("meshtastic.LoRaWANBridge.Uplink").msgclass
16
+ LoRaWANBridge::Downlink = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("meshtastic.LoRaWANBridge.Downlink").msgclass
17
+ LoRaWANBridge::PayloadChunk = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("meshtastic.LoRaWANBridge.PayloadChunk").msgclass
18
+ LoRaWANBridge::TxResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("meshtastic.LoRaWANBridge.TxResult").msgclass
19
+ LoRaWANBridge::TxResult::Status = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("meshtastic.LoRaWANBridge.TxResult.Status").enummodule
20
+ end
@@ -477,7 +477,9 @@ module Meshtastic
477
477
  decoder = Meshtastic::StoreAndForward
478
478
  when :TELEMETRY_APP
479
479
  decoder = Meshtastic::Telemetry
480
- when :TEXT_MESSAGE_APP, :UNKNOWN_APP
480
+ when :TEXT_MESSAGE_APP
481
+ return payload.dup.force_encoding(Encoding::UTF_8).scrub
482
+ when :UNKNOWN_APP
481
483
  decoder = Meshtastic::Data
482
484
  when :TRACEROUTE_APP
483
485
  decoder = Meshtastic::RouteDiscovery
@@ -213,14 +213,8 @@ module Meshtastic
213
213
  if message.is_a?(Hash)
214
214
  flat_message = message.values.join(' ')
215
215
 
216
- disp = false
217
- # disp = true if exclude_arr.none? { |exclude| flat_message.include?(exclude) } && (
218
- # include_arr.first == message[:id] ||
219
- # include_arr.all? { |include| flat_message.include?(include) }
220
- # )
221
-
222
- disp = true if !exclude_arr.intersect?(flat_message) &&
223
- include_arr.all? { |include| flat_message.include?(include) }
216
+ disp = exclude_arr.none? { |exc| flat_message.include?(exc) } &&
217
+ include_arr.all? { |inc| flat_message.include?(inc) }
224
218
 
225
219
  if disp
226
220
  if block_given?
@@ -276,33 +270,12 @@ module Meshtastic
276
270
  opts[:topic] = absolute_topic
277
271
  opts[:via] = :mqtt
278
272
 
279
- # TODO: Implement chunked message to deal with large messages
280
- text = opts[:text].to_s
281
- max_bytes = 231
282
- mui = Meshtastic::MeshInterface.new
273
+ text = opts.fetch(:text, 'SYN').to_s
274
+ max_len = Meshtastic::Constants::DATA_PAYLOAD_LEN
275
+ raise ArgumentError, "ERROR: Text Length > #{max_len} Bytes" if text.bytesize > max_len
283
276
 
284
- if text.bytesize > max_bytes
285
- total_chunks = (text.bytesize.to_f / max_bytes).ceil
286
- total_chunks.times do |i|
287
- chunk_num = i + 1
288
- chunk_prefix = " (#{chunk_num} of #{total_chunks})\n"
289
- chunk_prefix_len = chunk_prefix.bytesize
290
- start_index = i * (max_bytes - chunk_prefix_len)
291
- end_index = (start_index + (max_bytes - chunk_prefix_len)) - 1
292
- chunk = "#{chunk_prefix} #{text.byteslice(start_index..end_index)}"
293
- # This addresses a weird bug in the protocal if the first byte
294
- # is an h or H followed by a single byte, which returns
295
- # {} or {bitfiled: INT}
296
- opts[:text] = chunk
297
- protobuf_chunk = mui.send_text(opts)
298
- mqtt_obj.publish(absolute_topic, protobuf_chunk)
299
- sleep 0.3
300
- end
301
- else
302
- opts[:text] = " #{text}"
303
- protobuf_text = mui.send_text(opts)
304
- mqtt_obj.publish(absolute_topic, protobuf_text)
305
- end
277
+ opts[:text] = text
278
+ mqtt_obj.publish(absolute_topic, Meshtastic::MeshInterface.new.send_text(opts))
306
279
  rescue StandardError => e
307
280
  raise e
308
281
  end