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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +169 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +514 -0
  5. data/exe/rble +14 -0
  6. data/ext/macos_ble/Package.swift +20 -0
  7. data/ext/macos_ble/Sources/RBLEHelper/BLEManager.swift +783 -0
  8. data/ext/macos_ble/Sources/RBLEHelper/Protocol.swift +173 -0
  9. data/ext/macos_ble/Sources/RBLEHelper/main.swift +645 -0
  10. data/ext/macos_ble/extconf.rb +73 -0
  11. data/lib/rble/backend/base.rb +181 -0
  12. data/lib/rble/backend/bluez.rb +1279 -0
  13. data/lib/rble/backend/corebluetooth.rb +653 -0
  14. data/lib/rble/backend.rb +193 -0
  15. data/lib/rble/bluez/adapter.rb +169 -0
  16. data/lib/rble/bluez/async_call.rb +85 -0
  17. data/lib/rble/bluez/async_connection_operations.rb +492 -0
  18. data/lib/rble/bluez/async_gatt_operations.rb +249 -0
  19. data/lib/rble/bluez/async_introspection.rb +151 -0
  20. data/lib/rble/bluez/dbus_connection.rb +64 -0
  21. data/lib/rble/bluez/dbus_session.rb +344 -0
  22. data/lib/rble/bluez/device.rb +86 -0
  23. data/lib/rble/bluez/event_loop.rb +153 -0
  24. data/lib/rble/bluez/gatt_operation_queue.rb +129 -0
  25. data/lib/rble/bluez/pairing_agent.rb +132 -0
  26. data/lib/rble/bluez/pairing_session.rb +212 -0
  27. data/lib/rble/bluez/retry_policy.rb +55 -0
  28. data/lib/rble/bluez.rb +33 -0
  29. data/lib/rble/characteristic.rb +237 -0
  30. data/lib/rble/cli/adapter.rb +88 -0
  31. data/lib/rble/cli/characteristic_helpers.rb +154 -0
  32. data/lib/rble/cli/doctor.rb +309 -0
  33. data/lib/rble/cli/formatters/json.rb +122 -0
  34. data/lib/rble/cli/formatters/text.rb +157 -0
  35. data/lib/rble/cli/hex_dump.rb +48 -0
  36. data/lib/rble/cli/monitor.rb +129 -0
  37. data/lib/rble/cli/pair.rb +103 -0
  38. data/lib/rble/cli/paired.rb +22 -0
  39. data/lib/rble/cli/read.rb +55 -0
  40. data/lib/rble/cli/scan.rb +88 -0
  41. data/lib/rble/cli/show.rb +109 -0
  42. data/lib/rble/cli/status.rb +25 -0
  43. data/lib/rble/cli/unpair.rb +39 -0
  44. data/lib/rble/cli/value_parser.rb +211 -0
  45. data/lib/rble/cli/write.rb +196 -0
  46. data/lib/rble/cli.rb +152 -0
  47. data/lib/rble/company_ids.rb +90 -0
  48. data/lib/rble/connection.rb +539 -0
  49. data/lib/rble/device.rb +54 -0
  50. data/lib/rble/errors.rb +317 -0
  51. data/lib/rble/gatt/uuid_database.rb +395 -0
  52. data/lib/rble/scanner.rb +219 -0
  53. data/lib/rble/service.rb +41 -0
  54. data/lib/rble/tasks/check.rake +154 -0
  55. data/lib/rble/tasks/integration.rake +242 -0
  56. data/lib/rble/tasks.rb +8 -0
  57. data/lib/rble/version.rb +5 -0
  58. data/lib/rble.rb +62 -0
  59. 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