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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61f04c58cb881e95ff006f4f2300c017c31156ef4e95624fc6945e29be079ea8
4
- data.tar.gz: fa39514939d9122b38738840dfc21eca15e4efe50aad7cbb7c9d2f54d3c1a0d5
3
+ metadata.gz: 5f02b5c39c6c878904d2d2d7fb765bf0a66d48de021323ebc20ca55221e29f64
4
+ data.tar.gz: 592e7bf735c228217e2484f139f8682c8923d5ba1846a2f6212563df1f841a67
5
5
  SHA512:
6
- metadata.gz: b933ce6912f6cfc7a2e78bc53478f1793f7d8dc32842d3a8f7e7838d4ea21adbcb0b83266f9eba69285e1ca2ec8a7241be52a695a962bb1657e1030c66885857
7
- data.tar.gz: fde6712097aa9d534066fa00b6ba13d6de7058bdd9d775e8ed88fb440b507e0049571e50fd28aa8a0b0597c1b5ff92b3a8c3f9958bf0e511ad573ab98ee8f238
6
+ metadata.gz: ef44a3860a9a12450cff1902e4167377d47268a20a399741de32f5da0e0e6d9be9084d9485f4699e3726e23f7ff4de027b36b9a04e6b2164f27f1e438e591ee2
7
+ data.tar.gz: e355b9bf3afb4fc6394528ba85764307b9d7b2acc01d22dd85ae9c597388af793214a90d7cd7af0829d521ae2d430ab72e1fe365a9e92fd98bfd9bc3cd5bd309
data/.rubocop.yml CHANGED
@@ -1,6 +1,7 @@
1
1
  AllCops:
2
2
  UseCache: false
3
3
  NewCops: enable
4
+ SuggestExtensions: false
4
5
  Layout/LineLength:
5
6
  Max: 293
6
7
  Lint/UselessRescue:
data/.rubocop_todo.yml CHANGED
@@ -22,13 +22,6 @@ Layout/EmptyLineAfterMagicComment:
22
22
  Layout/EmptyLines:
23
23
  Enabled: false
24
24
 
25
- # Offense count: 2
26
- # This cop supports safe autocorrection (--autocorrect).
27
- # Configuration parameters: Width, EnforcedStyleAlignWith, AllowedPatterns.
28
- # SupportedStylesAlignWith: start_of_line, relative_to_receiver
29
- Layout/IndentationWidth:
30
- Exclude:
31
- - 'lib/meshtastic/serial_interface.rb'
32
25
 
33
26
  # Offense count: 26
34
27
  # This cop supports safe autocorrection (--autocorrect).
@@ -37,14 +30,6 @@ Layout/IndentationWidth:
37
30
  Layout/LineLength:
38
31
  Enabled: false
39
32
 
40
- # Offense count: 1
41
- # This cop supports safe autocorrection (--autocorrect).
42
- # Configuration parameters: EnforcedStyle, EnforcedStyleForEmptyBraces.
43
- # SupportedStyles: space, no_space
44
- # SupportedStylesForEmptyBraces: space, no_space
45
- Layout/SpaceBeforeBlockBraces:
46
- Exclude:
47
- - 'lib/meshtastic/serial_interface.rb'
48
33
 
49
34
  # Offense count: 49
50
35
  # Configuration parameters: AllowComments, AllowEmptyLambdas.
@@ -63,7 +48,6 @@ Lint/UnusedMethodArgument:
63
48
  # This cop supports safe autocorrection (--autocorrect).
64
49
  Lint/UselessAssignment:
65
50
  Exclude:
66
- - 'lib/meshtastic/serial_interface.rb'
67
51
  - 'lib/meshtastic/stream_interface.rb'
68
52
  - 'meshtastic.gemspec'
69
53
 
@@ -86,11 +70,6 @@ Style/CommentAnnotation:
86
70
  Style/Documentation:
87
71
  Enabled: false
88
72
 
89
- # Offense count: 1
90
- # This cop supports safe autocorrection (--autocorrect).
91
- Style/ExplicitBlockArgument:
92
- Exclude:
93
- - 'lib/meshtastic/serial_interface.rb'
94
73
 
95
74
  # Offense count: 1
96
75
  # This cop supports safe autocorrection (--autocorrect).
data/Gemfile CHANGED
@@ -20,6 +20,7 @@ gem 'rspec', '3.13.2'
20
20
  gem 'rubocop', '1.91.0'
21
21
  gem 'rubocop-rake', '0.7.1'
22
22
  gem 'rubocop-rspec', '3.10.2'
23
+ gem 'ruby-dbus', '0.25.0'
23
24
  gem 'rvm', '1.11.3.9'
24
25
  gem 'uart', '1.0.0'
25
26
  gem 'yard', '0.9.45'
data/README.md CHANGED
@@ -24,7 +24,7 @@ If bundler is not being used to manage dependencies, install the gem by executin
24
24
 
25
25
  ## Usage
26
26
 
27
- The primary interaction modules today are `Meshtastic::MQTT` (broker) and `Meshtastic::SerialInterface` (USB/UART). Examples for each follow.
27
+ The primary interaction modules today are `Meshtastic::MQTT` (broker), `Meshtastic::Serial` (USB/UART), and `Meshtastic::Bluetooth` (BLE via Linux BlueZ). Examples for each follow.
28
28
 
29
29
  ### MQTT
30
30
 
@@ -101,111 +101,108 @@ You should see something like this:
101
101
 
102
102
  Note where is says `channel: 93`. This is the `channel` value required to send messages in this particular example.
103
103
 
104
- ### Serial Interface
104
+ ### Serial and Bluetooth (send / receive)
105
105
 
106
- Talk directly to a Meshtastic node over USB/UART (`/dev/ttyUSB*`, `/dev/ttyACM*`). Unlike MQTT, the radio owns channel crypto for serial: payloads are sent *decoded* and the device encrypts with its configured channel key.
106
+ `Meshtastic::Serial` (USB/UART) and `Meshtastic::Bluetooth` (Linux BLE via BlueZ) talk to a local radio using the same client API. The radio encrypts with its configured channel keys. Payloads are sent decoded; `channel:` is the index on the device, not an MQTT channel hash.
107
107
 
108
- To inspect available methods and open a serial session:
108
+ Do not open Serial and Bluetooth to the same radio at once. Disconnect when finished (`ensure` is the reliable pattern). `send_text` / `send_data` report bytes written, not mesh delivery. `want_ack: true` requests a `ROUTING_APP` acknowledgment (`error_reason: NONE` means the local radio accepted the route). Incoming text is a UTF-8 string under `message[:packet][:decoded][:payload]`.
109
109
 
110
- ```ruby
111
- require 'meshtastic'
112
- Meshtastic::SerialInterface.help
113
- serial_obj = Meshtastic::SerialInterface.connect(
114
- block_dev: '/dev/ttyUSB0', # or /dev/ttyACM0
115
- baud: 115_200
116
- )
117
- puts serial_obj.inspect
118
- ```
110
+ Call `wait_for_config` before using `my_node_num` or sending. It raises `Timeout::Error` if the firmware never completes the handshake. Opening a port or pairing is not the same as a completed handshake.
119
111
 
120
- This code will dump every FromRadio packet (blocks until CTRL+C):
112
+ #### Serial (`Meshtastic::Serial`)
121
113
 
122
- ```ruby
123
- require 'meshtastic'
124
- serial_obj = Meshtastic::SerialInterface.connect(
125
- block_dev: '/dev/ttyUSB0',
126
- baud: 115_200
127
- )
128
- Meshtastic::SerialInterface.subscribe(
129
- serial_obj: serial_obj
130
- ) do |message|
131
- puts message.inspect
132
- end
133
- ```
134
-
135
- Filter with `include` / `exclude` (comma-delimited substrings), same idea as MQTT:
114
+ Use the device’s USB CDC port (`/dev/ttyACM*` or `/dev/ttyUSB*`). Enable Radio Configuration → Security → Serial Console (`security.serial_enabled`). That is not Module Configuration → Serial (`TEXTMSG` / `PROTO` on GPIO). Keep “Override Console Serial Port” off.
136
115
 
137
116
  ```ruby
138
117
  require 'meshtastic'
139
- serial_obj = Meshtastic::SerialInterface.connect(block_dev: '/dev/ttyUSB0')
140
- Meshtastic::SerialInterface.subscribe(
141
- serial_obj: serial_obj,
142
- include: 'TEXT_MESSAGE_APP',
143
- exclude: 'TELEMETRY_APP'
144
- ) do |message|
145
- puts message.inspect
118
+
119
+ serial_obj = nil
120
+ begin
121
+ serial_obj = Meshtastic::Serial.connect(block_dev: '/dev/ttyACM2', baud: 115_200)
122
+ Meshtastic::Serial.wait_for_config(serial_obj: serial_obj, timeout: 10)
123
+ puts "local node: !#{serial_obj[:my_node_num].to_s(16)}"
124
+
125
+ # Direct message, or to: '!ffffffff' for the shared channel.
126
+ Meshtastic::Serial.send_text(
127
+ serial_obj: serial_obj,
128
+ to: '!83726fb1',
129
+ channel: 0,
130
+ text: 'Hello over serial!',
131
+ want_ack: true
132
+ )
133
+
134
+ Meshtastic::Serial.subscribe(
135
+ serial_obj: serial_obj,
136
+ include: 'TEXT_MESSAGE_APP'
137
+ ) do |message|
138
+ packet = message[:packet]
139
+ puts "#{packet[:node_id_from]}: #{packet.dig(:decoded, :payload)}"
140
+ end
141
+ ensure
142
+ Meshtastic::Serial.disconnect(serial_obj: serial_obj)
146
143
  end
147
144
  ```
148
145
 
149
- Sending a message over serial:
146
+ Drive the loop yourself with `recv_from_radio(serial_obj:, timeout:)` (`0` polls, `nil` blocks) or `drain_from_radio`. A closed empty queue returns `nil`; an unplugged device raises `IOError`. If `wait_for_config` times out, the USB path is up but the Stream API is not (wrong port, Serial Console disabled, or firmware not responding).
150
147
 
151
- ```ruby
152
- require 'meshtastic'
153
- serial_obj = Meshtastic::SerialInterface.connect(
154
- block_dev: '/dev/ttyUSB0',
155
- baud: 115_200
156
- )
157
- Meshtastic::SerialInterface.send_text(
158
- serial_obj: serial_obj,
159
- to: '!ffffffff', # broadcast; or a node id like '!f33ddad5'
160
- channel: 0, # primary channel index on the radio
161
- text: 'Hello over serial!'
162
- )
163
- Meshtastic::SerialInterface.disconnect(serial_obj: serial_obj)
164
- ```
148
+ #### Bluetooth (`Meshtastic::Bluetooth`)
165
149
 
166
- A typical end-to-end send + receive session looks like this (connect once, send, then subscribe):
150
+ Linux only (BlueZ + `ruby-dbus`). Connect with a BLE address (`AA:BB:CC:DD:EE:FF`), not a mesh id (`!03d52a07`). Pair first; this gem does not guess a PIN. BLE writes unframed ToRadio protobufs (no UART `0x94 0xC3` header).
151
+
152
+ Scan:
167
153
 
168
154
  ```ruby
169
155
  require 'meshtastic'
156
+ Meshtastic::Bluetooth.scan(adapter: 'hci0', timeout: 5)
157
+ # => [{ address: 'E8:EE:03:D5:2A:07', name: '📺_2a07', paired: true }, ...]
158
+ ```
170
159
 
171
- serial_obj = Meshtastic::SerialInterface.connect(
172
- block_dev: '/dev/ttyUSB0',
173
- baud: 115_200
174
- )
175
-
176
- # Optional: give the device a moment to finish want_config / my_info
177
- sleep 2
178
- puts "local node: !#{serial_obj[:my_node_num].to_s(16)}" if serial_obj[:my_node_num]
179
-
180
- Meshtastic::SerialInterface.send_text(
181
- serial_obj: serial_obj,
182
- to: '!ffffffff',
183
- channel: 0,
184
- text: 'Hello over serial!'
185
- )
186
-
187
- # Blocks; CTRL+C disconnects cleanly
188
- Meshtastic::SerialInterface.subscribe(serial_obj: serial_obj) do |message|
189
- puts message.inspect
190
- end
160
+ Pair while discovery is running. Screen devices typically show a random 6-digit PIN:
161
+
162
+ ```text
163
+ bluetoothctl
164
+ agent KeyboardDisplay
165
+ default-agent
166
+ scan on
167
+ pair E8:EE:03:D5:2A:07
168
+ trust E8:EE:03:D5:2A:07
169
+ scan off
170
+ quit
191
171
  ```
192
172
 
193
- Non-blocking receive helpers (useful when you drive the loop yourself):
173
+ `Failed to pair: AuthenticationFailed` means the agent never got the PIN. `Device … not available` means scan first; the advertisement dropped.
194
174
 
195
- ```ruby
196
- # Pop one framed FromRadio (or nil on timeout)
197
- fr = Meshtastic::SerialInterface.recv_from_radio(timeout: 2)
198
- puts fr&.to_h
175
+ Send and receive (same options as Serial, `bluetooth_obj:` instead of `serial_obj:`):
199
176
 
200
- # Drain whatever is already queued
201
- Meshtastic::SerialInterface.drain_from_radio.each { |fr| puts fr.to_h }
177
+ ```ruby
178
+ require 'meshtastic'
202
179
 
203
- # Debug console / proto dumps collected by the RX thread
204
- puts Meshtastic::SerialInterface.dump_stdout_data(type: :console)
205
- puts Meshtastic::SerialInterface.dump_stdout_data(type: :proto).size
180
+ bluetooth_obj = nil
181
+ begin
182
+ bluetooth_obj = Meshtastic::Bluetooth.connect(address: 'E8:EE:03:D5:2A:07')
183
+ Meshtastic::Bluetooth.wait_for_config(bluetooth_obj: bluetooth_obj, timeout: 30)
184
+
185
+ Meshtastic::Bluetooth.send_text(
186
+ bluetooth_obj: bluetooth_obj,
187
+ to: '!83726fb1',
188
+ channel: 0,
189
+ text: 'Hello over BLE!',
190
+ want_ack: true
191
+ )
192
+
193
+ Meshtastic::Bluetooth.subscribe(
194
+ bluetooth_obj: bluetooth_obj,
195
+ include: 'TEXT_MESSAGE_APP'
196
+ ) do |message|
197
+ packet = message[:packet]
198
+ puts "#{packet[:node_id_from]}: #{packet.dig(:decoded, :payload)}"
199
+ end
200
+ ensure
201
+ Meshtastic::Bluetooth.disconnect(bluetooth_obj: bluetooth_obj)
202
+ end
206
203
  ```
207
204
 
208
- > **Note:** Serial path leaves mesh packets *decoded* and lets the radio encrypt with its configured channel key. The MQTT path still pre-encrypts with the PSK you supply via `psks:`. Channel index (`channel:`) on serial is the index configured on the *device*, not the MQTT channel hash.
205
+ Disconnect the phone’s Meshtastic BLE session while Linux is connected. After an aborted reconnect (`le-connection-abort-by-local`), `bluetoothctl disconnect <addr>` and wait a couple of seconds before `connect` again. Config dumps over BLE can take longer than serial; 30 seconds is a reasonable `wait_for_config` timeout.
209
206
 
210
207
  ## Contributing
211
208
 
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dbus'
4
+ require 'timeout'
5
+
6
+ module Meshtastic
7
+ module Bluetooth
8
+ # Linux BlueZ transport for raw Meshtastic protobuf messages.
9
+ class BlueZ # rubocop:disable Metrics/ClassLength
10
+ # https://github.com/meshtastic/python/blob/master/meshtastic/ble_interface.py
11
+ SERVICE_UUID = '6ba1b218-15a8-461f-9fa8-5dcae273eafd'
12
+ TORADIO_UUID = 'f75c76d2-129e-4dad-a1dd-7866124401e7'
13
+ FROMRADIO_UUID = '2c55e69e-4993-11ed-b878-0242ac120002'
14
+
15
+ def self.scan(adapter: 'hci0', timeout: 5)
16
+ new(address: '00:00:00:00:00:00', adapter: adapter, timeout: timeout).scan_adapter
17
+ end
18
+
19
+ def connect
20
+ @mutex.synchronize { connect_locked }
21
+ end
22
+
23
+ def connect_locked
24
+ @bus = DBus::ASystemBus.allocate
25
+ bounded { @bus.__send__(:initialize) }
26
+ objects = managed_objects
27
+ check_adapter(objects)
28
+ @device_path = objects.find do |_path, interfaces|
29
+ device = interfaces['org.bluez.Device1']
30
+ device && device['Address']&.casecmp?(@address) && device['Adapter'] == "/org/bluez/#{@adapter}"
31
+ end&.first
32
+ raise IOError, "Bluetooth device #{@address} not found on #{@adapter}; scan first" unless @device_path
33
+ raise IOError, "Bluetooth device is not Paired. Run: bluetoothctl pair #{@address} (enter the PIN shown by your radio), then retry." unless properties(@device_path, 'org.bluez.Device1')['Paired']
34
+
35
+ @disconnect_needed = true
36
+ call(@device_path, 'org.bluez.Device1', 'Connect')
37
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
38
+ until properties(@device_path, 'org.bluez.Device1')['ServicesResolved']
39
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
+ raise IOError, 'Bluetooth ServicesResolved timed out' unless remaining.positive?
41
+
42
+ sleep [0.1, remaining].min
43
+ end
44
+ @to_radio = characteristic(TORADIO_UUID)
45
+ @from_radio = characteristic(FROMRADIO_UUID)
46
+ self
47
+ rescue StandardError
48
+ close_locked
49
+ raise
50
+ end
51
+
52
+ def close
53
+ @mutex.synchronize { close_locked }
54
+ end
55
+
56
+ def close_locked
57
+ call(@device_path, 'org.bluez.Device1', 'Disconnect') if @bus && @disconnect_needed
58
+ ensure
59
+ @disconnect_needed = false
60
+ release_bus
61
+ end
62
+
63
+ def scan_adapter
64
+ started = false
65
+ @mutex.synchronize do
66
+ @bus = DBus::ASystemBus.allocate
67
+ bounded { @bus.__send__(:initialize) }
68
+ objects = managed_objects
69
+ check_adapter(objects)
70
+ adapter_path = "/org/bluez/#{@adapter}"
71
+ begin
72
+ call(adapter_path, 'org.bluez.Adapter1', 'StartDiscovery')
73
+ started = true
74
+ rescue DBus::Error
75
+ nil
76
+ end
77
+ sleep @timeout
78
+ managed_objects.filter_map do |_path, interfaces|
79
+ device = interfaces['org.bluez.Device1']
80
+ next unless device && device['Adapter'] == adapter_path
81
+ next unless Array(device['UUIDs']).any? { |uuid| uuid.to_s.casecmp?(SERVICE_UUID) }
82
+
83
+ { address: device['Address'], name: device['Name'], paired: device['Paired'] }
84
+ end
85
+ ensure
86
+ if @bus && started
87
+ begin
88
+ call("/org/bluez/#{@adapter}", 'org.bluez.Adapter1', 'StopDiscovery')
89
+ rescue DBus::Error
90
+ nil
91
+ end
92
+ end
93
+ release_bus
94
+ end
95
+ end
96
+
97
+ def connected!
98
+ raise IOError, 'Bluetooth device disconnected' unless @bus && @from_radio && properties(@device_path, 'org.bluez.Device1')['Connected']
99
+ end
100
+
101
+ def read
102
+ @mutex.synchronize do
103
+ connected!
104
+ call(@from_radio, 'org.bluez.GattCharacteristic1', 'ReadValue', ['a{sv}', {}]).first.pack('C*')
105
+ end
106
+ end
107
+
108
+ def write(bytes)
109
+ @mutex.synchronize do
110
+ connected!
111
+ # BlueZ request uses Write Request <= MTU-3, Prepare/Execute Write above it.
112
+ # Never fragment protobufs ourselves: each WriteValue is one message.
113
+ call(@to_radio, 'org.bluez.GattCharacteristic1', 'WriteValue', ['ay', bytes.bytes], ['a{sv}', { 'type' => %w[s request] }])
114
+ bytes.bytesize
115
+ end
116
+ end
117
+
118
+ def characteristic(uuid)
119
+ objects = managed_objects
120
+ service = objects.find do |_path, interfaces|
121
+ properties = interfaces['org.bluez.GattService1']
122
+ properties && properties['Device'] == @device_path && properties['UUID']&.casecmp?(SERVICE_UUID)
123
+ end&.first
124
+ raise IOError, 'Meshtastic GATT service not found on selected device' unless service
125
+
126
+ match = objects.find do |_path, interfaces|
127
+ properties = interfaces['org.bluez.GattCharacteristic1']
128
+ properties && properties['Service'] == service && properties['UUID']&.casecmp?(uuid)
129
+ end&.first
130
+ raise IOError, "Meshtastic GATT characteristic #{uuid} not found" unless match
131
+
132
+ match
133
+ end
134
+
135
+ def check_adapter(objects)
136
+ adapter = objects.dig("/org/bluez/#{@adapter}", 'org.bluez.Adapter1')
137
+ raise IOError, "Bluetooth adapter #{@adapter} not found" unless adapter
138
+ raise IOError, "Bluetooth adapter #{@adapter} is not powered" unless adapter['Powered']
139
+ end
140
+
141
+ def release_bus
142
+ socket = @bus&.message_queue&.socket
143
+ socket.close if socket && !socket.closed?
144
+ @bus = nil
145
+ end
146
+
147
+ def properties(path, interface)
148
+ call(path, 'org.freedesktop.DBus.Properties', 'GetAll', ['s', interface]).first
149
+ end
150
+
151
+ def managed_objects
152
+ call('/', 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects').first
153
+ end
154
+
155
+ def call(path, interface, member, *parameters)
156
+ message = DBus::Message.new(DBus::Message::METHOD_CALL)
157
+ message.destination = 'org.bluez'
158
+ message.path = path
159
+ message.interface = interface
160
+ message.member = member
161
+ parameters.each { |type, value| message.add_param(type, value) }
162
+ bounded { @bus.send_sync_or_async(message) }
163
+ end
164
+
165
+ # ruby-dbus 0.25 has no method timeout API. An interrupted bus must never
166
+ # be reused because its pending reply bookkeeping may be inconsistent.
167
+ def bounded(&)
168
+ Timeout.timeout(@timeout, &)
169
+ rescue Timeout::Error
170
+ release_bus
171
+ raise IOError, 'Bluetooth D-Bus operation timed out'
172
+ end
173
+
174
+ def initialize(address:, adapter: 'hci0', timeout: 15)
175
+ raise ArgumentError, 'Invalid Bluetooth address' unless address.is_a?(String) && /\A(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\z/.match?(address)
176
+
177
+ raise ArgumentError, 'Invalid Bluetooth adapter (expected hciN)' unless adapter.is_a?(String) && /\Ahci\d+\z/.match?(adapter)
178
+ raise ArgumentError, 'Invalid Bluetooth timeout (expected positive finite seconds)' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
179
+
180
+ @address = address
181
+ @adapter = adapter
182
+ @timeout = timeout
183
+ @mutex = Mutex.new
184
+ end
185
+ end
186
+ end
187
+ end