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,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dbus'
4
+ require_relative 'async_call'
5
+ require_relative 'async_introspection'
6
+ require_relative 'retry_policy'
7
+
8
+ module RBLE
9
+ module BlueZ
10
+ # Provides async GATT operations using AsyncCall + AsyncIntrospection patterns.
11
+ # Use this to perform GATT read/write/notify operations without blocking the event loop.
12
+ #
13
+ # This module is part of the v0.4.0 async architecture. It builds on AsyncCall
14
+ # and AsyncIntrospection to provide non-blocking GATT operations.
15
+ #
16
+ # Including class must:
17
+ # - Include AsyncCall module (provides async_call method)
18
+ # - Include AsyncIntrospection module (provides async_introspect method)
19
+ # - Have @service attribute pointing to D-Bus service
20
+ #
21
+ # @example
22
+ # include AsyncCall
23
+ # include AsyncIntrospection
24
+ # include AsyncGattOperations
25
+ #
26
+ # value = async_read_value("/org/bluez/hci0/.../char0011")
27
+ # async_write_value("/org/bluez/hci0/.../char0011", [0x01, 0x02])
28
+ # async_start_notify("/org/bluez/hci0/.../char0011")
29
+ # async_stop_notify("/org/bluez/hci0/.../char0011")
30
+ #
31
+ module AsyncGattOperations
32
+ include RetryPolicy
33
+ GATT_CHARACTERISTIC_INTERFACE = 'org.bluez.GattCharacteristic1'
34
+ PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
35
+
36
+ # Default timeouts (Claude's discretion per CONTEXT.md)
37
+ DEFAULT_READ_TIMEOUT = 5
38
+ DEFAULT_WRITE_TIMEOUT = 5
39
+ DEFAULT_NOTIFY_TIMEOUT = 5
40
+ WRITE_NO_RESPONSE_TIMEOUT = 1
41
+
42
+ # Async read characteristic value
43
+ #
44
+ # @param char_path [String] D-Bus characteristic path
45
+ # @param timeout [Numeric] Total timeout in seconds for the entire read operation (default: 5)
46
+ # @return [String] Binary string (ASCII-8BIT encoding)
47
+ # @raise [TimeoutError] if read times out
48
+ # @raise [ReadError] if read fails
49
+ # @raise [NotConnectedError] if device disconnected
50
+ # @raise [OperationInProgressError] if retries exhausted
51
+ def async_read_value(char_path, timeout: DEFAULT_READ_TIMEOUT)
52
+ uuid = extract_uuid_from_path(char_path)
53
+ deadline = Time.now + timeout
54
+
55
+ proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, timeout, 'ReadValue'))
56
+ char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
57
+
58
+ result = with_inprogress_retry do
59
+ async_call("ReadValue(#{uuid})", timeout: gatt_remaining_timeout(deadline, timeout, 'ReadValue')) do |queue, _request_id, cancelled|
60
+ char_iface.ReadValue({}) do |reply|
61
+ next if cancelled[0] # Discard late callback
62
+ if reply.is_a?(DBus::Error)
63
+ queue.push([reply, nil])
64
+ else
65
+ # Extract params from DBus::Message - ReadValue returns [Array<Byte>]
66
+ params = reply.respond_to?(:params) ? reply.params : [reply]
67
+ queue.push([nil, params.first])
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # Convert D-Bus byte array to binary string
74
+ bytes = result || []
75
+ bytes.map(&:to_i).pack('C*')
76
+ rescue DBus::Error => e
77
+ translate_gatt_error(e, char_path, 'Read')
78
+ end
79
+
80
+ # Async write characteristic value
81
+ #
82
+ # @param char_path [String] D-Bus characteristic path
83
+ # @param bytes [Array<Integer>] Byte array to write
84
+ # @param response [Boolean] Wait for write response (default: true)
85
+ # @param timeout [Numeric] Total timeout in seconds for the entire write operation (default: 5)
86
+ # @return [Boolean] true on success
87
+ # @raise [TimeoutError] if write times out
88
+ # @raise [WriteError] if write fails
89
+ # @raise [NotConnectedError] if device disconnected
90
+ # @raise [OperationInProgressError] if retries exhausted
91
+ def async_write_value(char_path, bytes, response: true, timeout: DEFAULT_WRITE_TIMEOUT)
92
+ uuid = extract_uuid_from_path(char_path)
93
+
94
+ # Write-without-response uses minimal timeout (CONTEXT.md decision)
95
+ effective_timeout = response ? timeout : WRITE_NO_RESPONSE_TIMEOUT
96
+ deadline = Time.now + effective_timeout
97
+
98
+ proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, effective_timeout, 'WriteValue'))
99
+ char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
100
+
101
+ # Build write options
102
+ type_value = response ? 'request' : 'command'
103
+ options = {
104
+ 'type' => DBus::Data::Variant.new(type_value, member_type: DBus::Type::STRING)
105
+ }
106
+
107
+ with_inprogress_retry do
108
+ async_call("WriteValue(#{uuid})", timeout: gatt_remaining_timeout(deadline, effective_timeout, 'WriteValue')) do |queue, _request_id, cancelled|
109
+ char_iface.WriteValue(bytes, options) do |reply|
110
+ next if cancelled[0] # Discard late callback
111
+ if reply.is_a?(DBus::Error)
112
+ queue.push([reply, nil])
113
+ else
114
+ queue.push([nil, :ok])
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ true
121
+ rescue DBus::Error => e
122
+ translate_gatt_error(e, char_path, 'Write')
123
+ end
124
+
125
+ # Async start notifications (idempotent)
126
+ #
127
+ # @param char_path [String] D-Bus characteristic path
128
+ # @param timeout [Numeric] Total timeout in seconds for the entire operation (default: 5)
129
+ # @return [Boolean] true on success
130
+ # @raise [TimeoutError] if call times out
131
+ # @raise [NotifyNotSupported] if characteristic doesn't support notifications
132
+ # @raise [NotConnectedError] if device disconnected
133
+ # @raise [OperationInProgressError] if retries exhausted
134
+ def async_start_notify(char_path, timeout: DEFAULT_NOTIFY_TIMEOUT)
135
+ uuid = extract_uuid_from_path(char_path)
136
+ deadline = Time.now + timeout
137
+
138
+ proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify'))
139
+ char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
140
+
141
+ # Check flags before calling (use async to avoid deadlock)
142
+ flags = async_get_property(char_path, GATT_CHARACTERISTIC_INTERFACE, 'Flags', timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify'))
143
+ unless flags.include?('notify') || flags.include?('indicate')
144
+ raise NotifyNotSupported.new(uuid, flags)
145
+ end
146
+
147
+ # Idempotent: return early if already notifying (use async to avoid deadlock)
148
+ notifying = async_get_property(char_path, GATT_CHARACTERISTIC_INTERFACE, 'Notifying', timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify'))
149
+ return true if notifying
150
+
151
+ with_inprogress_retry do
152
+ async_call("StartNotify(#{uuid})", timeout: gatt_remaining_timeout(deadline, timeout, 'StartNotify')) do |queue, _request_id, cancelled|
153
+ char_iface.StartNotify do |reply|
154
+ next if cancelled[0] # Discard late callback
155
+ if reply.is_a?(DBus::Error)
156
+ queue.push([reply, nil])
157
+ else
158
+ queue.push([nil, :ok])
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ true
165
+ rescue DBus::Error => e
166
+ translate_gatt_error(e, char_path, 'StartNotify')
167
+ end
168
+
169
+ # Async stop notifications
170
+ #
171
+ # @param char_path [String] D-Bus characteristic path
172
+ # @param timeout [Numeric] Total timeout in seconds for the entire operation (default: 5)
173
+ # @return [Boolean] true on success
174
+ # @raise [TimeoutError] if call times out
175
+ # @raise [NotConnectedError] if device disconnected
176
+ def async_stop_notify(char_path, timeout: DEFAULT_NOTIFY_TIMEOUT)
177
+ uuid = extract_uuid_from_path(char_path)
178
+ deadline = Time.now + timeout
179
+
180
+ proxy = async_introspect(char_path, timeout: gatt_remaining_timeout(deadline, timeout, 'StopNotify'))
181
+ char_iface = proxy[GATT_CHARACTERISTIC_INTERFACE]
182
+
183
+ async_call("StopNotify(#{uuid})", timeout: gatt_remaining_timeout(deadline, timeout, 'StopNotify')) do |queue, _request_id, cancelled|
184
+ char_iface.StopNotify do |reply|
185
+ next if cancelled[0] # Discard late callback
186
+ if reply.is_a?(DBus::Error)
187
+ queue.push([reply, nil])
188
+ else
189
+ queue.push([nil, :ok])
190
+ end
191
+ end
192
+ end
193
+
194
+ true
195
+ rescue DBus::Error => e
196
+ translate_gatt_error(e, char_path, 'StopNotify')
197
+ end
198
+
199
+ private
200
+
201
+ # Calculate remaining timeout from deadline, raising TimeoutError if expired
202
+ # @param deadline [Time] Absolute deadline
203
+ # @param original_timeout [Numeric] Original timeout for error message
204
+ # @param operation [String] Operation name for error message
205
+ # @return [Numeric] Remaining seconds (minimum 0.1 to allow one more attempt)
206
+ # @raise [TimeoutError] if deadline has passed
207
+ def gatt_remaining_timeout(deadline, original_timeout, operation)
208
+ remaining = deadline - Time.now
209
+ raise TimeoutError.new(operation, original_timeout) if remaining <= 0
210
+ [remaining, 0.1].max
211
+ end
212
+
213
+ # Translate D-Bus errors to RBLE errors with UUID
214
+ # CONTEXT.md decision: error messages must include characteristic UUID
215
+ def translate_gatt_error(error, char_path, operation)
216
+ uuid = extract_uuid_from_path(char_path) || char_path
217
+
218
+ case error.name
219
+ when 'org.bluez.Error.NotSupported'
220
+ raise NotSupportedError, "#{operation} on #{uuid}"
221
+ when 'org.bluez.Error.NotPermitted'
222
+ raise NotPermittedError, "#{operation} on #{uuid}"
223
+ when 'org.bluez.Error.NotAuthorized'
224
+ raise NotAuthorizedError, "#{operation} on #{uuid}"
225
+ when 'org.bluez.Error.AuthenticationFailed'
226
+ raise AuthenticationError.new
227
+ when 'org.bluez.Error.NotConnected'
228
+ raise ConnectionLostError.new(nil, "#{operation} on #{uuid}")
229
+ when 'org.bluez.Error.InProgress'
230
+ raise OperationInProgressError.new("#{operation} on #{uuid}")
231
+ when 'org.bluez.Error.InvalidValueLength'
232
+ raise WriteError, "Invalid data length for #{uuid} (org.bluez.Error.InvalidValueLength)"
233
+ when 'org.bluez.Error.InvalidOffset'
234
+ raise ReadError, "Invalid offset for #{uuid} (org.bluez.Error.InvalidOffset)"
235
+ when 'org.bluez.Error.Failed'
236
+ raise GATTError, "#{operation} failed on #{uuid}: #{error.message} (org.bluez.Error.Failed)"
237
+ else
238
+ raise GATTError, "#{operation} failed on #{uuid}: #{error.message} (#{error.name})"
239
+ end
240
+ end
241
+
242
+ # Extract UUID from characteristic path for error messages
243
+ # Path format: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/serviceXXXX/charYYYY
244
+ def extract_uuid_from_path(char_path)
245
+ char_path.split('/').last
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dbus'
4
+ require 'securerandom'
5
+ require_relative 'async_call'
6
+
7
+ module RBLE
8
+ module BlueZ
9
+ # Provides async D-Bus introspection with caching.
10
+ # Use this to introspect D-Bus objects without blocking the event loop.
11
+ #
12
+ # This module builds on AsyncCall to provide async introspection and
13
+ # GetManagedObjects calls. Results are cached for session lifetime.
14
+ #
15
+ # Including class must:
16
+ # - Include AsyncCall module (provides async_call method)
17
+ # - Provide a `service` method returning the D-Bus service
18
+ #
19
+ # @example
20
+ # include AsyncCall
21
+ # include AsyncIntrospection
22
+ #
23
+ # proxy = async_introspect("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF")
24
+ # managed = async_get_managed_objects
25
+ #
26
+ module AsyncIntrospection
27
+ INTROSPECTABLE_INTERFACE = 'org.freedesktop.DBus.Introspectable'
28
+ OBJECT_MANAGER_INTERFACE = 'org.freedesktop.DBus.ObjectManager'
29
+ ROOT_PATH = '/'
30
+
31
+ # Asynchronously introspect a D-Bus object by path
32
+ #
33
+ # Returns cached ProxyObject if available, otherwise performs async
34
+ # introspection and caches the result.
35
+ #
36
+ # @param path [String] D-Bus object path to introspect
37
+ # @param timeout [Numeric] Timeout in seconds (default: 5)
38
+ # @return [DBus::ProxyObject] Introspected proxy object
39
+ # @raise [TimeoutError] if introspection times out
40
+ # @raise [DBus::Error] if D-Bus call fails
41
+ def async_introspect(path, timeout: 5)
42
+ @introspection_cache ||= {}
43
+
44
+ # Return cached result if present
45
+ return @introspection_cache[path] if @introspection_cache.key?(path)
46
+
47
+ # Perform async introspection
48
+ proxy = perform_async_introspect(path, timeout: timeout)
49
+
50
+ # Cache and return
51
+ @introspection_cache[path] = proxy
52
+ proxy
53
+ end
54
+
55
+ # Asynchronously get all managed objects from ObjectManager
56
+ #
57
+ # Creates the D-Bus message directly to avoid triggering synchronous
58
+ # introspection when accessing proxy interfaces.
59
+ #
60
+ # @param timeout [Numeric] Timeout in seconds (default: 10)
61
+ # @return [Hash] path => { interface_name => { property => value } }
62
+ # @raise [TimeoutError] if call times out
63
+ # @raise [DBus::Error] if D-Bus call fails
64
+ def async_get_managed_objects(timeout: 10)
65
+ result = async_call('GetManagedObjects', timeout: timeout) do |queue, _request_id, cancelled|
66
+ # Create message directly to avoid sync introspection
67
+ msg = DBus::Message.new(DBus::Message::METHOD_CALL)
68
+ msg.path = ROOT_PATH
69
+ msg.interface = OBJECT_MANAGER_INTERFACE
70
+ msg.destination = service.name
71
+ msg.member = 'GetManagedObjects'
72
+ msg.sender = service.connection.unique_name
73
+
74
+ service.connection.send_sync_or_async(msg) do |reply, *params|
75
+ next if cancelled[0] # Discard late callback
76
+ if reply.is_a?(DBus::Error)
77
+ queue.push([reply, nil])
78
+ else
79
+ # params contains the method return values (first is the Hash)
80
+ queue.push([nil, params.first])
81
+ end
82
+ end
83
+ end
84
+
85
+ result
86
+ end
87
+
88
+ # Clear cache and re-introspect a path
89
+ #
90
+ # Forces a fresh introspection even if the path is cached.
91
+ #
92
+ # @param path [String] D-Bus object path to refresh
93
+ # @param timeout [Numeric] Timeout in seconds (default: 5)
94
+ # @return [DBus::ProxyObject] Fresh introspected proxy object
95
+ # @raise [TimeoutError] if introspection times out
96
+ # @raise [DBus::Error] if D-Bus call fails
97
+ def refresh_introspection(path, timeout: 5)
98
+ @introspection_cache ||= {}
99
+
100
+ # Clear cache entry
101
+ @introspection_cache.delete(path)
102
+
103
+ # Perform fresh introspection (will be cached by async_introspect)
104
+ async_introspect(path, timeout: timeout)
105
+ end
106
+
107
+ # Remove a path from the introspection cache
108
+ #
109
+ # Call this when a device is removed to clean up stale cache entries.
110
+ #
111
+ # @param path [String] D-Bus object path to remove from cache
112
+ # @return [void]
113
+ def clear_introspection(path)
114
+ @introspection_cache ||= {}
115
+ @introspection_cache.delete(path)
116
+ end
117
+
118
+ private
119
+
120
+ # Perform the actual async introspection
121
+ #
122
+ # Uses connection.introspect_data with a block to avoid triggering
123
+ # synchronous introspection when accessing proxy interfaces.
124
+ #
125
+ # @param path [String] D-Bus object path
126
+ # @param timeout [Numeric] Timeout in seconds
127
+ # @return [DBus::ProxyObject] Introspected proxy object
128
+ def perform_async_introspect(path, timeout:)
129
+ # Create proxy without triggering auto-introspection
130
+ proxy = service.object(path)
131
+
132
+ # Use async_call with direct introspect_data call (supports async via block)
133
+ xml = async_call('Introspect', timeout: timeout) do |queue, _request_id, cancelled|
134
+ service.connection.introspect_data(service.name, path) do |xml_result|
135
+ next if cancelled[0] # Discard late callback
136
+ if xml_result.is_a?(DBus::Error)
137
+ queue.push([xml_result, nil])
138
+ else
139
+ queue.push([nil, xml_result])
140
+ end
141
+ end
142
+ end
143
+
144
+ # Parse introspection XML into proxy object
145
+ DBus::ProxyObjectFactory.introspect_into(proxy, xml)
146
+
147
+ proxy
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module BlueZ
5
+ # Manages D-Bus system bus connection to BlueZ
6
+ class DBusConnection
7
+ attr_reader :bus, :service, :root_object
8
+
9
+ def initialize
10
+ @bus = nil
11
+ @service = nil
12
+ @root_object = nil
13
+ end
14
+
15
+ # Connect to D-Bus system bus
16
+ # Uses ASystemBus (non-singleton) to avoid state issues when switching between
17
+ # event loop processing (DBus::Main) and synchronous calls (send_sync)
18
+ # @raise [PermissionError] if permission denied
19
+ # @raise [Error] if BlueZ service not available
20
+ def connect
21
+ @bus = DBus::ASystemBus.new
22
+ @service = @bus.service(BLUEZ_SERVICE)
23
+
24
+ # Pre-introspect root for ObjectManager (REL-01: before async context)
25
+ # This ensures ObjectManager is available without async introspection
26
+ @root_object = @service.object('/')
27
+ @root_object.introspect
28
+ rescue DBus::Error => e
29
+ if e.message.include?('AccessDenied') || e.message.include?('Permission')
30
+ raise PermissionError.new('connect to D-Bus')
31
+ end
32
+ raise Error, "Failed to connect to BlueZ D-Bus service: #{e.message}"
33
+ end
34
+
35
+ # Get object manager interface
36
+ # @return [DBus::ProxyObjectInterface]
37
+ def object_manager
38
+ root_object[OBJECT_MANAGER_INTERFACE]
39
+ end
40
+
41
+ # Get a D-Bus object by path
42
+ # @param path [String] D-Bus object path
43
+ # @return [DBus::ProxyObject]
44
+ def object(path)
45
+ obj = @service.object(path)
46
+ obj.introspect
47
+ obj
48
+ end
49
+
50
+ # Check if connected
51
+ # @return [Boolean]
52
+ def connected?
53
+ !@bus.nil? && !@service.nil?
54
+ end
55
+
56
+ # Disconnect (cleanup)
57
+ def disconnect
58
+ @root_object = nil
59
+ @service = nil
60
+ @bus = nil
61
+ end
62
+ end
63
+ end
64
+ end