rble 0.7.0
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 +7 -0
- data/CHANGELOG.md +169 -0
- data/LICENSE.txt +21 -0
- data/README.md +514 -0
- data/exe/rble +14 -0
- data/ext/macos_ble/Package.swift +20 -0
- data/ext/macos_ble/Sources/RBLEHelper/BLEManager.swift +783 -0
- data/ext/macos_ble/Sources/RBLEHelper/Protocol.swift +173 -0
- data/ext/macos_ble/Sources/RBLEHelper/main.swift +645 -0
- data/ext/macos_ble/extconf.rb +73 -0
- data/lib/rble/backend/base.rb +181 -0
- data/lib/rble/backend/bluez.rb +1279 -0
- data/lib/rble/backend/corebluetooth.rb +653 -0
- data/lib/rble/backend.rb +193 -0
- data/lib/rble/bluez/adapter.rb +169 -0
- data/lib/rble/bluez/async_call.rb +85 -0
- data/lib/rble/bluez/async_connection_operations.rb +492 -0
- data/lib/rble/bluez/async_gatt_operations.rb +249 -0
- data/lib/rble/bluez/async_introspection.rb +151 -0
- data/lib/rble/bluez/dbus_connection.rb +64 -0
- data/lib/rble/bluez/dbus_session.rb +344 -0
- data/lib/rble/bluez/device.rb +86 -0
- data/lib/rble/bluez/event_loop.rb +153 -0
- data/lib/rble/bluez/gatt_operation_queue.rb +129 -0
- data/lib/rble/bluez/pairing_agent.rb +132 -0
- data/lib/rble/bluez/pairing_session.rb +212 -0
- data/lib/rble/bluez/retry_policy.rb +55 -0
- data/lib/rble/bluez.rb +33 -0
- data/lib/rble/characteristic.rb +237 -0
- data/lib/rble/cli/adapter.rb +88 -0
- data/lib/rble/cli/characteristic_helpers.rb +154 -0
- data/lib/rble/cli/doctor.rb +309 -0
- data/lib/rble/cli/formatters/json.rb +122 -0
- data/lib/rble/cli/formatters/text.rb +157 -0
- data/lib/rble/cli/hex_dump.rb +48 -0
- data/lib/rble/cli/monitor.rb +129 -0
- data/lib/rble/cli/pair.rb +103 -0
- data/lib/rble/cli/paired.rb +22 -0
- data/lib/rble/cli/read.rb +55 -0
- data/lib/rble/cli/scan.rb +88 -0
- data/lib/rble/cli/show.rb +109 -0
- data/lib/rble/cli/status.rb +25 -0
- data/lib/rble/cli/unpair.rb +39 -0
- data/lib/rble/cli/value_parser.rb +211 -0
- data/lib/rble/cli/write.rb +196 -0
- data/lib/rble/cli.rb +152 -0
- data/lib/rble/company_ids.rb +90 -0
- data/lib/rble/connection.rb +539 -0
- data/lib/rble/device.rb +54 -0
- data/lib/rble/errors.rb +317 -0
- data/lib/rble/gatt/uuid_database.rb +395 -0
- data/lib/rble/scanner.rb +219 -0
- data/lib/rble/service.rb +41 -0
- data/lib/rble/tasks/check.rake +154 -0
- data/lib/rble/tasks/integration.rake +242 -0
- data/lib/rble/tasks.rb +8 -0
- data/lib/rble/version.rb +5 -0
- data/lib/rble.rb +62 -0
- metadata +120 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
# Immutable representation of a GATT characteristic (data only)
|
|
5
|
+
#
|
|
6
|
+
# @!attribute uuid [String] Characteristic UUID (128-bit format)
|
|
7
|
+
# @!attribute flags [Array<String>] Capability flags: "read", "write", "write-without-response", "notify", "indicate"
|
|
8
|
+
# @!attribute service_uuid [String] Parent service UUID
|
|
9
|
+
Characteristic = Data.define(:uuid, :flags, :service_uuid) do
|
|
10
|
+
def initialize(uuid:, flags: [], service_uuid: nil)
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get short UUID for standard characteristics
|
|
15
|
+
def short_uuid
|
|
16
|
+
if uuid =~ /^0000([0-9a-f]{4})-0000-1000-8000-00805f9b34fb$/i
|
|
17
|
+
Regexp.last_match(1).downcase
|
|
18
|
+
else
|
|
19
|
+
uuid
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check if characteristic supports reading
|
|
24
|
+
def readable?
|
|
25
|
+
flags.include?('read')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if characteristic supports writing with response
|
|
29
|
+
def writable?
|
|
30
|
+
flags.include?('write')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if characteristic supports writing without response
|
|
34
|
+
def writable_without_response?
|
|
35
|
+
flags.include?('write-without-response')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if characteristic supports notifications
|
|
39
|
+
def notifiable?
|
|
40
|
+
flags.include?('notify')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if characteristic supports indications
|
|
44
|
+
def indicatable?
|
|
45
|
+
flags.include?('indicate')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if characteristic supports any form of subscription
|
|
49
|
+
def subscribable?
|
|
50
|
+
notifiable? || indicatable?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Active characteristic with read/write/subscribe operations
|
|
55
|
+
#
|
|
56
|
+
# Returned by Connection service discovery. Wraps immutable Characteristic
|
|
57
|
+
# data with operations that interact with the device via the backend.
|
|
58
|
+
#
|
|
59
|
+
# @example Reading a value
|
|
60
|
+
# hr_char = service.characteristic('2a37')
|
|
61
|
+
# value = hr_char.read # => binary string
|
|
62
|
+
# bytes = hr_char.read_bytes # => [0x10, 0x65, ...]
|
|
63
|
+
#
|
|
64
|
+
# @example Writing a value
|
|
65
|
+
# char.write([0x01, 0x02]) # write with response
|
|
66
|
+
# char.write([0x01], response: false) # write without response
|
|
67
|
+
#
|
|
68
|
+
# @example Subscribing to notifications
|
|
69
|
+
# char.subscribe do |value|
|
|
70
|
+
# puts "Got notification: #{value.bytes.inspect}"
|
|
71
|
+
# end
|
|
72
|
+
# # ... later ...
|
|
73
|
+
# char.unsubscribe
|
|
74
|
+
#
|
|
75
|
+
class ActiveCharacteristic
|
|
76
|
+
attr_reader :uuid, :flags, :service_uuid, :path, :descriptors
|
|
77
|
+
|
|
78
|
+
# Create an active characteristic
|
|
79
|
+
# @param characteristic [Characteristic] Underlying characteristic data
|
|
80
|
+
# @param path [String] D-Bus object path
|
|
81
|
+
# @param connection [Connection] Parent connection (for state checks)
|
|
82
|
+
# @param backend [Backend::Base] Backend for GATT operations
|
|
83
|
+
# @param descriptors [Array<Hash>] Descriptor info hashes with :uuid and :path keys
|
|
84
|
+
def initialize(characteristic:, path:, connection:, backend:, descriptors: [])
|
|
85
|
+
@uuid = characteristic.uuid
|
|
86
|
+
@flags = characteristic.flags
|
|
87
|
+
@service_uuid = characteristic.service_uuid
|
|
88
|
+
@path = path
|
|
89
|
+
@connection = connection
|
|
90
|
+
@backend = backend
|
|
91
|
+
@descriptors = descriptors
|
|
92
|
+
@subscribed = false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get short UUID for standard characteristics (e.g., "2a37" from full UUID)
|
|
96
|
+
# @return [String] Short UUID if standard, otherwise full UUID
|
|
97
|
+
def short_uuid
|
|
98
|
+
if uuid =~ /^0000([0-9a-f]{4})-0000-1000-8000-00805f9b34fb$/i
|
|
99
|
+
Regexp.last_match(1).downcase
|
|
100
|
+
else
|
|
101
|
+
uuid
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if characteristic supports reading
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def readable?
|
|
108
|
+
flags.include?('read')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if characteristic supports writing with response
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def writable?
|
|
114
|
+
flags.include?('write')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Check if characteristic supports writing without response
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def writable_without_response?
|
|
120
|
+
flags.include?('write-without-response')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if characteristic supports notifications
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def notifiable?
|
|
126
|
+
flags.include?('notify')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Check if characteristic supports indications
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def indicatable?
|
|
132
|
+
flags.include?('indicate')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if characteristic supports any form of subscription
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def subscribable?
|
|
138
|
+
notifiable? || indicatable?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Read the characteristic value
|
|
142
|
+
# @param timeout [Numeric] Read timeout in seconds
|
|
143
|
+
# @return [String] Binary string (ASCII-8BIT encoding)
|
|
144
|
+
# @raise [NotSupportedError] if characteristic doesn't support read
|
|
145
|
+
# @raise [NotConnectedError] if not connected
|
|
146
|
+
# @raise [ReadError] if read fails
|
|
147
|
+
def read(timeout: 30)
|
|
148
|
+
raise NotSupportedError, 'read' unless readable?
|
|
149
|
+
raise NotConnectedError unless @connection.connected?
|
|
150
|
+
|
|
151
|
+
# Serialize GATT operations through the connection's queue
|
|
152
|
+
enqueue_gatt_operation do
|
|
153
|
+
@backend.read_characteristic(@path, connection: @connection, timeout: timeout)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Read the characteristic value as byte array
|
|
158
|
+
# @param timeout [Numeric] Read timeout in seconds
|
|
159
|
+
# @return [Array<Integer>] Byte array
|
|
160
|
+
# @raise [NotSupportedError] if characteristic doesn't support read
|
|
161
|
+
# @raise [NotConnectedError] if not connected
|
|
162
|
+
# @raise [ReadError] if read fails
|
|
163
|
+
def read_bytes(timeout: 30)
|
|
164
|
+
read(timeout: timeout).bytes
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Write a value to the characteristic
|
|
168
|
+
# @param data [String, Array<Integer>] Data to write (binary string or byte array)
|
|
169
|
+
# @param response [Boolean] Wait for write response (default: true)
|
|
170
|
+
# @param timeout [Numeric] Write timeout in seconds
|
|
171
|
+
# @return [Boolean] true on success
|
|
172
|
+
# @raise [NotSupportedError] if write type not supported
|
|
173
|
+
# @raise [NotConnectedError] if not connected
|
|
174
|
+
# @raise [WriteError] if write fails
|
|
175
|
+
def write(data, response: true, timeout: 30)
|
|
176
|
+
if response
|
|
177
|
+
raise NotSupportedError, 'write' unless writable?
|
|
178
|
+
else
|
|
179
|
+
raise NotSupportedError, 'write-without-response' unless writable_without_response?
|
|
180
|
+
end
|
|
181
|
+
raise NotConnectedError unless @connection.connected?
|
|
182
|
+
|
|
183
|
+
# Serialize GATT operations through the connection's queue
|
|
184
|
+
enqueue_gatt_operation do
|
|
185
|
+
@backend.write_characteristic(@path, data, connection: @connection, response: response, timeout: timeout)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Subscribe to characteristic notifications/indications
|
|
190
|
+
# @yield [String] Called with value (binary string) on each notification
|
|
191
|
+
# @return [Boolean] true on success
|
|
192
|
+
# @raise [ArgumentError] if no block given
|
|
193
|
+
# @raise [NotifyNotSupported] if characteristic doesn't support notifications
|
|
194
|
+
# @raise [NotConnectedError] if not connected
|
|
195
|
+
# @raise [NotifyError] if subscription fails
|
|
196
|
+
def subscribe(&block)
|
|
197
|
+
raise ArgumentError, 'Block required' unless block_given?
|
|
198
|
+
raise NotifyNotSupported.new(short_uuid, flags) unless subscribable?
|
|
199
|
+
raise NotConnectedError unless @connection.connected?
|
|
200
|
+
|
|
201
|
+
# Serialize GATT operations through the connection's queue
|
|
202
|
+
enqueue_gatt_operation do
|
|
203
|
+
@backend.subscribe_characteristic(@path, connection: @connection, &block)
|
|
204
|
+
end
|
|
205
|
+
@subscribed = true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Unsubscribe from notifications
|
|
209
|
+
# @return [void]
|
|
210
|
+
def unsubscribe
|
|
211
|
+
return unless @subscribed
|
|
212
|
+
|
|
213
|
+
@backend.unsubscribe_characteristic(@path)
|
|
214
|
+
@subscribed = false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Check if currently subscribed to notifications
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
def subscribed?
|
|
220
|
+
@subscribed
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# Enqueue a GATT operation through the connection's queue
|
|
226
|
+
# Falls back to direct execution if queue is not available (non-BlueZ backend)
|
|
227
|
+
# @yield Block containing the GATT operation
|
|
228
|
+
# @return [Object] Result from the block
|
|
229
|
+
def enqueue_gatt_operation(&block)
|
|
230
|
+
if @connection.respond_to?(:gatt_queue)
|
|
231
|
+
@connection.gatt_queue.enqueue(&block)
|
|
232
|
+
else
|
|
233
|
+
block.call
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
module CLI
|
|
5
|
+
class AdapterCli < Thor
|
|
6
|
+
def self.namespace
|
|
7
|
+
"adapter"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class_option :json, type: :boolean, default: false,
|
|
11
|
+
desc: "Output as JSON"
|
|
12
|
+
class_option :adapter, type: :string,
|
|
13
|
+
desc: "Adapter name, e.g. hci1"
|
|
14
|
+
|
|
15
|
+
desc "list", "Show all available Bluetooth adapters"
|
|
16
|
+
def list
|
|
17
|
+
formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
|
|
18
|
+
adapter_list = Backend.for_platform.adapters
|
|
19
|
+
formatter.adapter_list(adapter_list)
|
|
20
|
+
rescue RBLE::Error => e
|
|
21
|
+
$stderr.puts "Error: #{e.message}"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "power STATE", "Turn adapter power on or off"
|
|
26
|
+
def power(state)
|
|
27
|
+
bool = parse_on_off(state)
|
|
28
|
+
name = resolve_adapter_name
|
|
29
|
+
Backend.for_platform.adapter_set_property(name, :powered, bool)
|
|
30
|
+
formatter_for_options.adapter_confirm("Adapter #{bool ? 'powered on' : 'powered off'}")
|
|
31
|
+
rescue RBLE::Error => e
|
|
32
|
+
$stderr.puts "Error: #{e.message}. Run `rble doctor` to diagnose."
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "discoverable STATE", "Set discoverable mode on or off"
|
|
37
|
+
def discoverable(state)
|
|
38
|
+
bool = parse_on_off(state)
|
|
39
|
+
name = resolve_adapter_name
|
|
40
|
+
Backend.for_platform.adapter_set_property(name, :discoverable, bool)
|
|
41
|
+
formatter_for_options.adapter_confirm("Discoverable mode #{bool ? 'enabled' : 'disabled'}")
|
|
42
|
+
rescue RBLE::Error => e
|
|
43
|
+
$stderr.puts "Error: #{e.message}. Run `rble doctor` to diagnose."
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc "pairable STATE", "Set pairable mode on or off"
|
|
48
|
+
def pairable(state)
|
|
49
|
+
bool = parse_on_off(state)
|
|
50
|
+
name = resolve_adapter_name
|
|
51
|
+
Backend.for_platform.adapter_set_property(name, :pairable, bool)
|
|
52
|
+
formatter_for_options.adapter_confirm("Pairable mode #{bool ? 'enabled' : 'disabled'}")
|
|
53
|
+
rescue RBLE::Error => e
|
|
54
|
+
$stderr.puts "Error: #{e.message}. Run `rble doctor` to diagnose."
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "name NAME", "Set adapter friendly name"
|
|
59
|
+
def name(new_name)
|
|
60
|
+
adapter_name = resolve_adapter_name
|
|
61
|
+
Backend.for_platform.adapter_set_property(adapter_name, :alias, new_name)
|
|
62
|
+
formatter_for_options.adapter_confirm("Adapter name set to '#{new_name}'")
|
|
63
|
+
rescue RBLE::Error => e
|
|
64
|
+
$stderr.puts "Error: #{e.message}. Run `rble doctor` to diagnose."
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse_on_off(state)
|
|
71
|
+
case state.downcase
|
|
72
|
+
when "on" then true
|
|
73
|
+
when "off" then false
|
|
74
|
+
else
|
|
75
|
+
raise Thor::Error, "Invalid state '#{state}'. Use 'on' or 'off'."
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_adapter_name
|
|
80
|
+
options["adapter"] || Backend.for_platform.default_adapter_name
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def formatter_for_options
|
|
84
|
+
options["json"] ? Formatters::Json.new : Formatters::Text.new
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rble/gatt/uuid_database'
|
|
4
|
+
require_relative 'value_parser'
|
|
5
|
+
require_relative 'hex_dump'
|
|
6
|
+
|
|
7
|
+
module RBLE
|
|
8
|
+
module CLI
|
|
9
|
+
# Shared helpers for GATT commands (read, write, monitor).
|
|
10
|
+
#
|
|
11
|
+
# Provides UUID normalization, characteristic discovery, connection
|
|
12
|
+
# management, and value formatting used by all three commands.
|
|
13
|
+
#
|
|
14
|
+
# @example Including in a command class
|
|
15
|
+
# class Read
|
|
16
|
+
# include CharacteristicHelpers
|
|
17
|
+
#
|
|
18
|
+
# def execute
|
|
19
|
+
# connection = connect_and_discover(@address, timeout: @timeout)
|
|
20
|
+
# char = find_characteristic(connection, "2a37")
|
|
21
|
+
# puts format_value(char.uuid, char.read)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
module CharacteristicHelpers
|
|
25
|
+
# Bluetooth Base UUID suffix for short UUID expansion
|
|
26
|
+
BLUETOOTH_BASE_SUFFIX = "-0000-1000-8000-00805f9b34fb"
|
|
27
|
+
|
|
28
|
+
# Normalize user UUID input to full 128-bit lowercase UUID.
|
|
29
|
+
#
|
|
30
|
+
# Handles:
|
|
31
|
+
# - Short 4-char UUIDs: "2a37" -> "00002a37-0000-1000-8000-00805f9b34fb"
|
|
32
|
+
# - "0x" prefix: "0x2a37" -> expanded
|
|
33
|
+
# - Case-insensitive: "2A37" -> lowercase
|
|
34
|
+
# - Full 128-bit UUIDs: passed through lowercase
|
|
35
|
+
#
|
|
36
|
+
# @param input [String] UUID from user input
|
|
37
|
+
# @return [String] Full 128-bit UUID in lowercase
|
|
38
|
+
def normalize_char_uuid(input)
|
|
39
|
+
stripped = input.sub(/\A0x/i, '').downcase
|
|
40
|
+
case stripped.length
|
|
41
|
+
when 4
|
|
42
|
+
"0000#{stripped}#{BLUETOOTH_BASE_SUFFIX}"
|
|
43
|
+
when 8
|
|
44
|
+
"#{stripped}#{BLUETOOTH_BASE_SUFFIX}"
|
|
45
|
+
else
|
|
46
|
+
stripped
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Find an ActiveCharacteristic across all services on a connection.
|
|
51
|
+
#
|
|
52
|
+
# Searches all services and their characteristics for a match by UUID.
|
|
53
|
+
# First match wins (per design decision -- no disambiguation).
|
|
54
|
+
#
|
|
55
|
+
# @param connection [Connection] Connected device with discovered services
|
|
56
|
+
# @param uuid_input [String] UUID from user (short, full, or with 0x prefix)
|
|
57
|
+
# @return [ActiveCharacteristic] The found characteristic
|
|
58
|
+
# @raise [RBLE::Error] if characteristic not found
|
|
59
|
+
def find_characteristic(connection, uuid_input)
|
|
60
|
+
normalized = normalize_char_uuid(uuid_input)
|
|
61
|
+
short_input = uuid_input.sub(/\A0x/i, '').downcase
|
|
62
|
+
|
|
63
|
+
connection.services.each do |service|
|
|
64
|
+
service.characteristics.each do |char|
|
|
65
|
+
return char if char.uuid.downcase == normalized || char.short_uuid == short_input
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
raise RBLE::Error,
|
|
70
|
+
"Characteristic #{uuid_input} not found on device. " \
|
|
71
|
+
"Run `rble show <address>` to see available characteristics."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Connect to a device, discover services, and return the connection.
|
|
75
|
+
#
|
|
76
|
+
# Reuses Show's connect_with_retry pattern: one retry for
|
|
77
|
+
# ConnectionTimeout/ConnectionFailed, no retry for DeviceNotFound.
|
|
78
|
+
# Status messages are written to stderr.
|
|
79
|
+
#
|
|
80
|
+
# @param address [String] Device MAC address
|
|
81
|
+
# @param timeout [Numeric] Connection timeout in seconds
|
|
82
|
+
# @return [Connection] Connected device with services discovered
|
|
83
|
+
# @raise [SystemExit] on unrecoverable connection errors
|
|
84
|
+
def connect_and_discover(address, timeout:)
|
|
85
|
+
connection = connect_with_retry(address, timeout: timeout)
|
|
86
|
+
$stderr.puts "Discovering services..."
|
|
87
|
+
connection.discover_services
|
|
88
|
+
connection
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Format a characteristic value for display.
|
|
92
|
+
#
|
|
93
|
+
# Known UUIDs are parsed with smart parsers (e.g., "72 bpm").
|
|
94
|
+
# Unknown UUIDs are displayed as hex+ASCII dump.
|
|
95
|
+
#
|
|
96
|
+
# @param uuid [String] Characteristic UUID
|
|
97
|
+
# @param raw_bytes [String] Binary string of raw characteristic data
|
|
98
|
+
# @return [String] Formatted value string
|
|
99
|
+
def format_value(uuid, raw_bytes)
|
|
100
|
+
if ValueParser.known?(uuid)
|
|
101
|
+
ValueParser.parse(uuid, raw_bytes)
|
|
102
|
+
else
|
|
103
|
+
HexDump.format(raw_bytes)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get human-readable name + UUID display for a characteristic.
|
|
108
|
+
#
|
|
109
|
+
# Standard UUIDs: "Heart Rate Measurement (2a37)"
|
|
110
|
+
# Vendor UUIDs: "12345678-1234-1234-1234-123456789abd"
|
|
111
|
+
#
|
|
112
|
+
# @param uuid [String] Characteristic UUID (full 128-bit)
|
|
113
|
+
# @return [String] Display name
|
|
114
|
+
def resolve_char_name(uuid)
|
|
115
|
+
name = RBLE::GATT::UUIDDatabase.resolve(uuid, type: :characteristic)
|
|
116
|
+
short = RBLE::GATT::UUIDDatabase.extract_short_uuid(uuid)
|
|
117
|
+
|
|
118
|
+
if name
|
|
119
|
+
if RBLE::GATT::UUIDDatabase.standard_uuid?(uuid)
|
|
120
|
+
"#{name} (#{short})"
|
|
121
|
+
else
|
|
122
|
+
"#{name} (#{uuid})"
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
short == uuid.downcase ? uuid : short
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Connect with one retry on timeout/connection failure.
|
|
132
|
+
# No retry for DeviceNotFoundError.
|
|
133
|
+
#
|
|
134
|
+
# @param address [String] Device MAC address
|
|
135
|
+
# @param timeout [Numeric] Connection timeout in seconds
|
|
136
|
+
# @return [Connection] Connected device
|
|
137
|
+
# @raise [SystemExit] on unrecoverable errors
|
|
138
|
+
def connect_with_retry(address, timeout:)
|
|
139
|
+
$stderr.puts "Connecting to #{address}..."
|
|
140
|
+
RBLE.connect(address, timeout: timeout)
|
|
141
|
+
rescue RBLE::DeviceNotFoundError
|
|
142
|
+
raise # No retry for device not found
|
|
143
|
+
rescue RBLE::ConnectionTimeoutError, RBLE::ConnectionFailed
|
|
144
|
+
$stderr.puts "Connection failed, retrying..."
|
|
145
|
+
begin
|
|
146
|
+
RBLE.connect(address, timeout: timeout)
|
|
147
|
+
rescue RBLE::ConnectionTimeoutError, RBLE::ConnectionFailed => e
|
|
148
|
+
$stderr.puts "Error: #{e.message}"
|
|
149
|
+
exit 1
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|