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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'backend/base'
4
+ require_relative 'errors'
5
+
6
+ module RBLE
7
+ module Backend
8
+ @backend_mutex = Mutex.new
9
+ @backend_symbol = nil # :bluez, :corebluetooth
10
+ @backend_instance = nil # The actual backend object
11
+ @backend_frozen = false # Locked after first BLE operation
12
+
13
+ # Returns the current backend symbol
14
+ # @return [Symbol] :bluez or :corebluetooth
15
+ def self.backend
16
+ @backend_mutex.synchronize do
17
+ ensure_backend_selected
18
+ @backend_symbol
19
+ end
20
+ end
21
+
22
+ # Set the backend to use (before first BLE operation)
23
+ # @param value [Symbol, String] Backend name (:bluez, :corebluetooth)
24
+ # @raise [BackendAlreadySelectedError] if backend already frozen
25
+ # @raise [ArgumentError] if backend is invalid for platform
26
+ # @raise [BackendUnavailableError] if backend is unavailable
27
+ def self.backend=(value)
28
+ @backend_mutex.synchronize do
29
+ if @backend_frozen
30
+ raise BackendAlreadySelectedError,
31
+ "Cannot change backend after BLE operations. Current: #{@backend_symbol}"
32
+ end
33
+ perform_backend_selection(value)
34
+ end
35
+ end
36
+
37
+ # Get backend information
38
+ # @return [Hash] { name: Symbol, version: String, capabilities: Array }
39
+ def self.backend_info
40
+ {
41
+ name: backend,
42
+ version: backend_version,
43
+ capabilities: backend_capabilities
44
+ }
45
+ end
46
+
47
+ # List available backends for the current platform
48
+ # @return [Array<Symbol>] Available backend symbols
49
+ def self.available_backends
50
+ case RUBY_PLATFORM
51
+ when /darwin/
52
+ [:corebluetooth]
53
+ when /linux/
54
+ [:bluez]
55
+ else
56
+ []
57
+ end
58
+ end
59
+
60
+ # Returns the appropriate backend for the current platform (singleton)
61
+ # @return [Backend::Base] Platform-specific backend instance
62
+ # @raise [Error] if platform is not supported
63
+ def self.for_platform
64
+ backend_instance
65
+ end
66
+
67
+ # Reset the singleton instance (for testing)
68
+ # @api private
69
+ def self.reset!
70
+ @backend_mutex.synchronize do
71
+ @backend_instance&.shutdown if @backend_instance.respond_to?(:shutdown)
72
+ @backend_instance = nil
73
+ @backend_symbol = nil
74
+ @backend_frozen = false
75
+ end
76
+ end
77
+
78
+ class << self
79
+ private
80
+
81
+ # Ensure a backend is selected (lazy selection)
82
+ # Must be called within @backend_mutex.synchronize
83
+ def ensure_backend_selected
84
+ return if @backend_symbol
85
+
86
+ # Check ENV override first (highest priority)
87
+ env_backend = ENV['RBLE_BACKEND']
88
+ if env_backend && !env_backend.empty?
89
+ perform_backend_selection(env_backend)
90
+ else
91
+ perform_backend_selection(platform_default)
92
+ end
93
+ end
94
+
95
+ # Get the default backend for the current platform
96
+ # @return [Symbol] Default backend symbol
97
+ # @raise [Error] if platform is not supported
98
+ def platform_default
99
+ case RUBY_PLATFORM
100
+ when /darwin/
101
+ :corebluetooth
102
+ when /linux/
103
+ :bluez
104
+ else
105
+ raise Error, "Unsupported platform: #{RUBY_PLATFORM}"
106
+ end
107
+ end
108
+
109
+ # Perform backend selection with validation
110
+ # Must be called within @backend_mutex.synchronize
111
+ # @param value [Symbol, String] Backend name
112
+ def perform_backend_selection(value)
113
+ sym = normalize_backend(value)
114
+ validate_platform_compatibility!(sym)
115
+ validate_backend_availability!(sym)
116
+ @backend_symbol = sym
117
+ @backend_instance = nil # Lazy load
118
+ end
119
+
120
+ # Normalize backend value to symbol
121
+ # @param value [Symbol, String] Backend name
122
+ # @return [Symbol] Normalized backend symbol
123
+ def normalize_backend(value)
124
+ value.to_s.downcase.to_sym
125
+ end
126
+
127
+ # Validate backend is compatible with current platform
128
+ # @param sym [Symbol] Backend symbol
129
+ # @raise [ArgumentError] if backend is invalid for platform
130
+ def validate_platform_compatibility!(sym)
131
+ available = available_backends
132
+ return if available.include?(sym)
133
+
134
+ platform_name = case RUBY_PLATFORM
135
+ when /darwin/ then 'macOS'
136
+ when /linux/ then 'Linux'
137
+ else RUBY_PLATFORM
138
+ end
139
+
140
+ raise ArgumentError,
141
+ "Backend :#{sym} not available on #{platform_name}. Available: #{available.map { |b| ":#{b}" }.join(', ')}"
142
+ end
143
+
144
+ # Validate backend can be used
145
+ # @param sym [Symbol] Backend symbol
146
+ # @raise [BackendUnavailableError] if backend is unavailable
147
+ def validate_backend_availability!(sym)
148
+ # :bluez and :corebluetooth validated at use time
149
+ end
150
+
151
+ # Get or create backend instance
152
+ # @return [Backend::Base] Backend instance
153
+ def backend_instance
154
+ @backend_mutex.synchronize do
155
+ ensure_backend_selected
156
+ @backend_instance ||= load_backend(@backend_symbol)
157
+ @backend_frozen = true
158
+ @backend_instance
159
+ end
160
+ end
161
+
162
+ # Create backend instance
163
+ # @param sym [Symbol] Backend symbol
164
+ # @return [Backend::Base] Backend instance
165
+ def load_backend(sym)
166
+ case sym
167
+ when :bluez
168
+ require_relative 'backend/bluez'
169
+ BlueZ.new
170
+ when :corebluetooth
171
+ require_relative 'backend/corebluetooth'
172
+ CoreBluetooth.new
173
+ else
174
+ raise Error, "Unknown backend: #{sym}"
175
+ end
176
+ end
177
+
178
+ # Get backend version
179
+ # @return [String] Version string or 'unknown'
180
+ def backend_version
181
+ inst = backend_instance
182
+ inst.respond_to?(:version) ? inst.version : 'unknown'
183
+ end
184
+
185
+ # Get backend capabilities
186
+ # @return [Array] Capabilities array or empty array
187
+ def backend_capabilities
188
+ inst = backend_instance
189
+ inst.respond_to?(:capabilities) ? inst.capabilities : []
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module BlueZ
5
+ # Wraps a BlueZ Bluetooth adapter (org.bluez.Adapter1)
6
+ class Adapter
7
+ attr_reader :path, :name
8
+
9
+ # Create an Adapter from a DBusSession
10
+ # @param session [DBusSession] Active D-Bus session
11
+ # @param path [String] D-Bus object path (e.g., "/org/bluez/hci0")
12
+ # @return [Adapter]
13
+ def self.new_from_session(session, path)
14
+ adapter = allocate
15
+ adapter.instance_variable_set(:@session, session)
16
+ adapter.instance_variable_set(:@path, path)
17
+ adapter.instance_variable_set(:@name, path.split('/').last)
18
+ # Async introspect for non-blocking setup
19
+ proxy = session.async_introspect(path, timeout: 5)
20
+ adapter.instance_variable_set(:@object, proxy)
21
+ adapter.instance_variable_set(:@adapter_iface, proxy[ADAPTER_INTERFACE])
22
+ adapter.instance_variable_set(:@properties_iface, proxy[PROPERTIES_INTERFACE])
23
+ adapter
24
+ end
25
+
26
+ # Get adapter MAC address
27
+ # @return [String]
28
+ def address
29
+ @session.async_get_property(@path, ADAPTER_INTERFACE, 'Address', timeout: 5)
30
+ end
31
+
32
+ # Check if adapter is powered on
33
+ # @return [Boolean]
34
+ def powered?
35
+ @session.async_get_property(@path, ADAPTER_INTERFACE, 'Powered', timeout: 5)
36
+ end
37
+
38
+ # Check if discovery is in progress
39
+ # @return [Boolean]
40
+ def discovering?
41
+ @session.async_get_property(@path, ADAPTER_INTERFACE, 'Discovering', timeout: 5)
42
+ end
43
+
44
+ # Check if adapter is discoverable
45
+ # @return [Boolean]
46
+ def discoverable?
47
+ @session.async_get_property(@path, ADAPTER_INTERFACE, 'Discoverable', timeout: 5)
48
+ end
49
+
50
+ # Check if adapter is pairable
51
+ # @return [Boolean]
52
+ def pairable?
53
+ @session.async_get_property(@path, ADAPTER_INTERFACE, 'Pairable', timeout: 5)
54
+ end
55
+
56
+ # Get adapter alias (friendly name)
57
+ # @return [String]
58
+ def alias_name
59
+ @session.async_get_property(@path, ADAPTER_INTERFACE, 'Alias', timeout: 5)
60
+ end
61
+
62
+ # Set adapter power state
63
+ # @param value [Boolean] true to power on, false to power off
64
+ def set_powered(value)
65
+ @session.async_set_property(@path, ADAPTER_INTERFACE, 'Powered', value, timeout: 5)
66
+ end
67
+
68
+ # Set adapter discoverable mode
69
+ # @param value [Boolean] true to enable, false to disable
70
+ def set_discoverable(value)
71
+ @session.async_set_property(@path, ADAPTER_INTERFACE, 'Discoverable', value, timeout: 5)
72
+ end
73
+
74
+ # Set adapter pairable mode
75
+ # @param value [Boolean] true to enable, false to disable
76
+ def set_pairable(value)
77
+ @session.async_set_property(@path, ADAPTER_INTERFACE, 'Pairable', value, timeout: 5)
78
+ end
79
+
80
+ # Set adapter alias (friendly name)
81
+ # @param name [String] New alias
82
+ def set_alias(name)
83
+ @session.async_set_property(@path, ADAPTER_INTERFACE, 'Alias', name, timeout: 5)
84
+ end
85
+
86
+ # Set discovery filter before starting scan
87
+ # @param service_uuids [Array<String>, nil] Filter by these UUIDs
88
+ # @param allow_duplicates [Boolean] Receive every advertisement
89
+ # @param rssi [Integer, nil] Minimum RSSI value
90
+ # @param pathloss [Integer, nil] Maximum path loss
91
+ def set_discovery_filter(service_uuids: nil, allow_duplicates: false, rssi: nil, pathloss: nil)
92
+ # Build filter options for async method
93
+ filter_options = {
94
+ transport: 'le',
95
+ duplicate_data: allow_duplicates
96
+ }
97
+
98
+ # Add optional filters
99
+ if service_uuids && !service_uuids.empty?
100
+ filter_options[:uuids] = service_uuids.map { |uuid| normalize_uuid(uuid) }
101
+ end
102
+ filter_options[:rssi] = rssi if rssi
103
+ filter_options[:pathloss] = pathloss if pathloss
104
+
105
+ # Store filter for application in start_discovery via async_start_discovery
106
+ @pending_filter = filter_options
107
+ rescue DBus::Error => e
108
+ raise ScanError, "Failed to set discovery filter: #{e.message}"
109
+ end
110
+
111
+ # Start BLE discovery
112
+ def start_discovery
113
+ # Async path: idempotent, handles powered? check internally
114
+ filter = @pending_filter || { transport: 'le' }
115
+ @session.async_start_discovery(@path, filter: filter, timeout: 10)
116
+ @pending_filter = nil
117
+ rescue DBus::Error => e
118
+ if e.message.include?('InProgress')
119
+ raise ScanInProgressError
120
+ elsif e.message.include?('NotReady') || e.message.include?('NotPowered')
121
+ raise AdapterDisabledError.new(@name)
122
+ end
123
+ raise ScanError, "Failed to start discovery: #{e.message}"
124
+ end
125
+
126
+ # Stop BLE discovery
127
+ def stop_discovery
128
+ # Async path: idempotent, no need to check discovering?
129
+ @session.async_stop_discovery(@path, timeout: 10)
130
+ rescue DBus::Error => e
131
+ # Ignore "not discovering" errors during cleanup
132
+ unless e.message.include?('NotAuthorized') || e.message.include?('NotDiscovering')
133
+ raise ScanError, "Failed to stop discovery: #{e.message}"
134
+ end
135
+ end
136
+
137
+ # Get adapter info as hash
138
+ # @return [Hash] with adapter properties
139
+ def to_h
140
+ {
141
+ name: @name,
142
+ address: address,
143
+ powered: powered?,
144
+ discoverable: discoverable?,
145
+ pairable: pairable?,
146
+ alias: alias_name,
147
+ discovering: discovering?
148
+ }
149
+ end
150
+
151
+ private
152
+
153
+ # Normalize UUID to BlueZ format (lowercase, with hyphens for 128-bit)
154
+ def normalize_uuid(uuid)
155
+ uuid = uuid.to_s.downcase.delete('-')
156
+
157
+ # 16-bit UUIDs (4 hex chars) stay short
158
+ return uuid if uuid.length == 4
159
+
160
+ # 32-bit and 128-bit get full format with hyphens
161
+ if uuid.length == 32
162
+ "#{uuid[0..7]}-#{uuid[8..11]}-#{uuid[12..15]}-#{uuid[16..19]}-#{uuid[20..31]}"
163
+ else
164
+ uuid
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dbus'
4
+ require 'securerandom'
5
+
6
+ module RBLE
7
+ module BlueZ
8
+ # Provides async D-Bus call wrapper with Queue-based result delivery.
9
+ # Use this to make D-Bus method calls without blocking the event loop.
10
+ #
11
+ # This module is the foundation for the v0.4.0 async architecture. By routing
12
+ # D-Bus method call results through Thread::Queue instead of blocking on the
13
+ # D-Bus socket, we eliminate the conflict between DBus::Main and synchronous calls.
14
+ #
15
+ # @example
16
+ # include AsyncCall
17
+ #
18
+ # result = async_call('ReadValue', timeout: 5) do |queue, request_id, cancelled|
19
+ # proxy.ReadValue({}) do |reply, *params|
20
+ # next if cancelled[0] # Discard late callback
21
+ # if reply.is_a?(DBus::Error)
22
+ # queue.push([reply, nil])
23
+ # else
24
+ # queue.push([reply, params])
25
+ # end
26
+ # end
27
+ # end
28
+ #
29
+ module AsyncCall
30
+ # Count of late callbacks received after timeout (for metrics)
31
+ attr_reader :late_callback_count
32
+ # Execute a D-Bus call asynchronously with Queue-based result delivery
33
+ #
34
+ # @param operation [String] Operation name for logging/errors
35
+ # @param timeout [Numeric] Timeout in seconds (default: 5)
36
+ # @yield [queue, request_id, cancelled] Block that performs async D-Bus call
37
+ # @yieldparam queue [Thread::Queue] Push [reply, params] to deliver result
38
+ # @yieldparam request_id [String] Request ID for log correlation
39
+ # @yieldparam cancelled [Array<Boolean>] Mutable container - check cancelled[0] before pushing
40
+ # @return [Array, nil] Method call result params
41
+ # @raise [TimeoutError] if timeout exceeded
42
+ # @raise [SessionClosedError] if session closed while operation pending
43
+ # @raise [DBus::Error] propagated from D-Bus (for caller to translate)
44
+ def async_call(operation, timeout: 5)
45
+ queue = Thread::Queue.new
46
+ request_id = SecureRandom.hex(4)
47
+ start_time = Time.now
48
+ cancelled = [false] # Mutable container for cancellation state
49
+
50
+ # Track queue for shutdown notification (if @pending_queues exists)
51
+ @pending_queues&.push(queue)
52
+
53
+ RBLE.logger&.debug("[RBLE] #{request_id} Starting #{operation}")
54
+ warn " [RBLE] #{request_id} #{operation} timeout=#{timeout.round(2)}s" if RBLE.trace
55
+
56
+ yield(queue, request_id, cancelled)
57
+
58
+ result = queue.pop(timeout: timeout)
59
+ elapsed = Time.now - start_time
60
+ warn " [RBLE] #{request_id} #{operation} -> #{result.nil? ? 'TIMEOUT' : 'ok'} (#{elapsed.round(2)}s)" if RBLE.trace
61
+
62
+ if result.nil?
63
+ cancelled[0] = true
64
+ @late_callback_count = (@late_callback_count || 0) + 1
65
+ RBLE.logger&.warn("[RBLE] #{request_id} #{operation} timed out after #{elapsed.round(3)}s (late callbacks: #{@late_callback_count})")
66
+ raise TimeoutError.new(operation, timeout)
67
+ end
68
+
69
+ # Check for session close marker
70
+ if result.is_a?(Array) && result.first == :session_closed
71
+ raise SessionClosedError.new
72
+ end
73
+
74
+ RBLE.logger&.debug("[RBLE] #{request_id} #{operation} completed in #{elapsed.round(3)}s")
75
+
76
+ reply, params = result
77
+ raise reply if reply.is_a?(DBus::Error)
78
+
79
+ params
80
+ ensure
81
+ @pending_queues&.delete(queue)
82
+ end
83
+ end
84
+ end
85
+ end