Class: Meshtastic::Bluetooth::BlueZ

Inherits:
Object
  • Object
show all
Defined in:
lib/meshtastic/bluetooth/bluez.rb

Overview

Linux BlueZ transport for raw Meshtastic protobuf messages.

Direct Known Subclasses

Admin::Firmware::BLE::BlueZ

Constant Summary collapse

SERVICE_UUID =
'6ba1b218-15a8-461f-9fa8-5dcae273eafd'
TORADIO_UUID =
'f75c76d2-129e-4dad-a1dd-7866124401e7'
FROMRADIO_UUID =
'2c55e69e-4993-11ed-b878-0242ac120002'

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(address:, adapter: 'hci0', timeout: 15) ⇒ BlueZ

Returns a new instance of BlueZ.

Raises:

  • (ArgumentError)


176
177
178
179
180
181
182
183
184
185
186
# File 'lib/meshtastic/bluetooth/bluez.rb', line 176

def initialize(address:, adapter: 'hci0', timeout: 15)
  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)

  raise ArgumentError, 'Invalid Bluetooth adapter (expected hciN)' unless adapter.is_a?(String) && /\Ahci\d+\z/.match?(adapter)
  raise ArgumentError, 'Invalid Bluetooth timeout (expected positive finite seconds)' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?

  @address = address
  @adapter = adapter
  @timeout = timeout
  @mutex = Mutex.new
end

Class Method Details

.authorsObject



188
189
190
# File 'lib/meshtastic/bluetooth/bluez.rb', line 188

public_class_method def self.authors
  "AUTHOR(S):\n          0day Inc. <[email protected]>\n        "
end

.helpObject



192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/meshtastic/bluetooth/bluez.rb', line 192

public_class_method def self.help
  puts "USAGE:
    # Scan BlueZ for nearby Meshtastic BLE advertisements.
    #{self}.scan(
      adapter: 'optional - BlueZ adapter name such as hci0',
      timeout: 'optional - discovery duration in seconds (default: 5)'
    )

    # Print the AUTHOR(S) string for this module.
    #{self}.authors
  "
end

.scan(opts = {}) ⇒ Object



15
16
17
18
19
# File 'lib/meshtastic/bluetooth/bluez.rb', line 15

public_class_method def self.scan(opts = {})
  adapter = opts[:adapter] || 'hci0'
  timeout = opts[:timeout] || 5
  new(address: '00:00:00:00:00:00', adapter: adapter, timeout: timeout).scan_adapter
end

Instance Method Details

#boundedObject

ruby-dbus 0.25 has no method timeout API. An interrupted bus must never be reused because its pending reply bookkeeping may be inconsistent.



169
170
171
172
173
174
# File 'lib/meshtastic/bluetooth/bluez.rb', line 169

def bounded(&)
  Timeout.timeout(@timeout, &)
rescue Timeout::Error
  release_bus
  raise IOError, 'Bluetooth D-Bus operation timed out'
end

#call(path, interface, member, *parameters) ⇒ Object



157
158
159
160
161
162
163
164
165
# File 'lib/meshtastic/bluetooth/bluez.rb', line 157

def call(path, interface, member, *parameters)
  message = DBus::Message.new(DBus::Message::METHOD_CALL)
  message.destination = 'org.bluez'
  message.path = path
  message.interface = interface
  message.member = member
  parameters.each { |type, value| message.add_param(type, value) }
  bounded { @bus.send_sync_or_async(message) }
end

#characteristic(uuid) ⇒ Object

Raises:

  • (IOError)


120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/meshtastic/bluetooth/bluez.rb', line 120

def characteristic(uuid)
  objects = managed_objects
  service = objects.find do |_path, interfaces|
    properties = interfaces['org.bluez.GattService1']
    properties && properties['Device'] == @device_path && properties['UUID']&.casecmp?(SERVICE_UUID)
  end&.first
  raise IOError, 'Meshtastic GATT service not found on selected device' unless service

  match = objects.find do |_path, interfaces|
    properties = interfaces['org.bluez.GattCharacteristic1']
    properties && properties['Service'] == service && properties['UUID']&.casecmp?(uuid)
  end&.first
  raise IOError, "Meshtastic GATT characteristic #{uuid} not found" unless match

  match
end

#check_adapter(objects) ⇒ Object

Raises:

  • (IOError)


137
138
139
140
141
# File 'lib/meshtastic/bluetooth/bluez.rb', line 137

def check_adapter(objects)
  adapter = objects.dig("/org/bluez/#{@adapter}", 'org.bluez.Adapter1')
  raise IOError, "Bluetooth adapter #{@adapter} not found" unless adapter
  raise IOError, "Bluetooth adapter #{@adapter} is not powered" unless adapter['Powered']
end

#closeObject



54
55
56
# File 'lib/meshtastic/bluetooth/bluez.rb', line 54

def close
  @mutex.synchronize { close_locked }
end

#close_lockedObject



58
59
60
61
62
63
# File 'lib/meshtastic/bluetooth/bluez.rb', line 58

def close_locked
  call(@device_path, 'org.bluez.Device1', 'Disconnect') if @bus && @disconnect_needed
ensure
  @disconnect_needed = false
  release_bus
end

#connectObject



21
22
23
# File 'lib/meshtastic/bluetooth/bluez.rb', line 21

def connect
  @mutex.synchronize { connect_locked }
end

#connect_lockedObject



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/meshtastic/bluetooth/bluez.rb', line 25

def connect_locked
  @bus = DBus::ASystemBus.allocate
  bounded { @bus.__send__(:initialize) }
  objects = managed_objects
  check_adapter(objects)
  @device_path = objects.find do |_path, interfaces|
    device = interfaces['org.bluez.Device1']
    device && device['Address']&.casecmp?(@address) && device['Adapter'] == "/org/bluez/#{@adapter}"
  end&.first
  raise IOError, "Bluetooth device #{@address} not found on #{@adapter}; scan first" unless @device_path
  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']

  @disconnect_needed = true
  call(@device_path, 'org.bluez.Device1', 'Connect')
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
  until properties(@device_path, 'org.bluez.Device1')['ServicesResolved']
    remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
    raise IOError, 'Bluetooth ServicesResolved timed out' unless remaining.positive?

    sleep [0.1, remaining].min
  end
  @to_radio = characteristic(TORADIO_UUID)
  @from_radio = characteristic(FROMRADIO_UUID)
  self
rescue StandardError
  close_locked
  raise
end

#connected!Object

Raises:

  • (IOError)


99
100
101
# File 'lib/meshtastic/bluetooth/bluez.rb', line 99

def connected!
  raise IOError, 'Bluetooth device disconnected' unless @bus && @from_radio && properties(@device_path, 'org.bluez.Device1')['Connected']
end

#managed_objectsObject



153
154
155
# File 'lib/meshtastic/bluetooth/bluez.rb', line 153

def managed_objects
  call('/', 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects').first
end

#properties(path, interface) ⇒ Object



149
150
151
# File 'lib/meshtastic/bluetooth/bluez.rb', line 149

def properties(path, interface)
  call(path, 'org.freedesktop.DBus.Properties', 'GetAll', ['s', interface]).first
end

#readObject



103
104
105
106
107
108
# File 'lib/meshtastic/bluetooth/bluez.rb', line 103

def read
  @mutex.synchronize do
    connected!
    call(@from_radio, 'org.bluez.GattCharacteristic1', 'ReadValue', ['a{sv}', {}]).first.pack('C*')
  end
end

#release_busObject



143
144
145
146
147
# File 'lib/meshtastic/bluetooth/bluez.rb', line 143

def release_bus
  socket = @bus&.message_queue&.socket
  socket.close if socket && !socket.closed?
  @bus = nil
end

#scan_adapterObject



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/meshtastic/bluetooth/bluez.rb', line 65

def scan_adapter
  started = false
  @mutex.synchronize do
    @bus = DBus::ASystemBus.allocate
    bounded { @bus.__send__(:initialize) }
    objects = managed_objects
    check_adapter(objects)
    adapter_path = "/org/bluez/#{@adapter}"
    begin
      call(adapter_path, 'org.bluez.Adapter1', 'StartDiscovery')
      started = true
    rescue DBus::Error
      nil
    end
    sleep @timeout
    managed_objects.filter_map do |_path, interfaces|
      device = interfaces['org.bluez.Device1']
      next unless device && device['Adapter'] == adapter_path
      next unless Array(device['UUIDs']).any? { |uuid| uuid.to_s.casecmp?(SERVICE_UUID) }

      { address: device['Address'], name: device['Name'], paired: device['Paired'] }
    end
  ensure
    if @bus && started
      begin
        call("/org/bluez/#{@adapter}", 'org.bluez.Adapter1', 'StopDiscovery')
      rescue DBus::Error
        nil
      end
    end
    release_bus
  end
end

#write(bytes) ⇒ Object



110
111
112
113
114
115
116
117
118
# File 'lib/meshtastic/bluetooth/bluez.rb', line 110

def write(bytes)
  @mutex.synchronize do
    connected!
    # BlueZ request uses Write Request <= MTU-3, Prepare/Execute Write above it.
    # Never fragment protobufs ourselves: each WriteValue is one message.
    call(@to_radio, 'org.bluez.GattCharacteristic1', 'WriteValue', ['ay', bytes.bytes], ['a{sv}', { 'type' => %w[s request] }])
    bytes.bytesize
  end
end