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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'characteristic_helpers'
4
+
5
+ module RBLE
6
+ module CLI
7
+ class Monitor
8
+ include CharacteristicHelpers
9
+
10
+ def initialize(options)
11
+ @options = options
12
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
13
+ @address = options["address"]
14
+ @characteristic_uuid = options["characteristic"]
15
+ @timeout = options["timeout"] || 10
16
+ @reconnect = options["reconnect"] || false
17
+ @stop = false
18
+ end
19
+
20
+ def execute
21
+ if @reconnect
22
+ reconnect_loop
23
+ else
24
+ single_session
25
+ end
26
+ rescue RBLE::DeviceNotFoundError => e
27
+ $stderr.puts "Error: #{e.message}"
28
+ $stderr.puts "Run `rble scan` first to discover nearby devices."
29
+ exit 1
30
+ rescue RBLE::Error => e
31
+ $stderr.puts "Error: #{e.message}"
32
+ exit 1
33
+ end
34
+
35
+ private
36
+
37
+ # Run a single monitor session: connect, subscribe, stream until stop or disconnect.
38
+ def single_session
39
+ connection = connect_and_discover(@address, timeout: @timeout)
40
+
41
+ begin
42
+ char = find_characteristic(connection, @characteristic_uuid)
43
+
44
+ unless char.subscribable?
45
+ $stderr.puts "Error: Characteristic #{@characteristic_uuid} does not support notifications."
46
+ $stderr.puts "Supported: #{char.flags.join(', ')}"
47
+ exit 1
48
+ end
49
+
50
+ stream_notifications(connection, char)
51
+ ensure
52
+ connection.disconnect if connection.connected?
53
+ end
54
+ end
55
+
56
+ # Reconnect loop: retry single_session on connection errors until @stop is set.
57
+ def reconnect_loop
58
+ loop do
59
+ begin
60
+ single_session
61
+ rescue RBLE::DeviceNotFoundError
62
+ raise # No retry for device not found
63
+ rescue RBLE::ConnectionTimeoutError, RBLE::ConnectionFailed, RBLE::Error => e
64
+ break if @stop
65
+
66
+ $stderr.puts "Error: #{e.message}"
67
+ $stderr.puts "Reconnecting in 2s..."
68
+ interruptible_sleep(2)
69
+ next
70
+ end
71
+
72
+ break if @stop
73
+
74
+ $stderr.puts "Device disconnected."
75
+ $stderr.puts "Reconnecting in 2s..."
76
+ interruptible_sleep(2)
77
+ end
78
+ end
79
+
80
+ # Sleep in short intervals, checking @stop flag for responsiveness
81
+ # @param seconds [Numeric] Total sleep duration
82
+ def interruptible_sleep(seconds)
83
+ intervals = (seconds / 0.1).ceil
84
+ intervals.times do
85
+ break if @stop
86
+ sleep 0.1
87
+ end
88
+ end
89
+
90
+ # Subscribe and stream notifications until @stop flag or disconnect.
91
+ def stream_notifications(connection, char)
92
+ name = resolve_char_name(char.uuid)
93
+ char_display_name = RBLE::GATT::UUIDDatabase.resolve(char.uuid, type: :characteristic)
94
+
95
+ $stderr.puts "Subscribing to #{name}..."
96
+
97
+ char.subscribe do |value|
98
+ timestamp = Time.now
99
+ formatted = format_value(char.uuid, value)
100
+ @formatter.monitor_value(
101
+ address: @address,
102
+ uuid: char.uuid,
103
+ name: char_display_name,
104
+ raw: value,
105
+ formatted: formatted,
106
+ timestamp: timestamp
107
+ )
108
+ end
109
+
110
+ $stderr.puts "Subscribed, waiting for notifications..."
111
+
112
+ prev_handler = trap("INT") { @stop = true }
113
+
114
+ begin
115
+ while !@stop && connection.connected?
116
+ sleep 0.1
117
+ end
118
+
119
+ unless connection.connected?
120
+ $stderr.puts "Device disconnected." unless @stop
121
+ end
122
+ ensure
123
+ trap("INT", prev_handler || "DEFAULT")
124
+ char.unsubscribe if connection.connected?
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module CLI
5
+ # IO handler for interactive terminal prompts during pairing
6
+ class TerminalIOHandler
7
+ def display(msg)
8
+ $stderr.puts msg
9
+ end
10
+
11
+ def prompt(msg)
12
+ $stderr.print msg
13
+ $stdin.gets&.strip
14
+ end
15
+
16
+ def confirm(msg)
17
+ $stderr.print msg
18
+ response = $stdin.gets&.strip&.downcase
19
+ response == 'y' || response == 'yes'
20
+ end
21
+ end
22
+
23
+ # Null IO handler for JSON mode (no interactive prompts on stdout)
24
+ class NullIOHandler
25
+ def display(_msg); end
26
+
27
+ def prompt(_msg)
28
+ nil
29
+ end
30
+
31
+ def confirm(_msg)
32
+ false
33
+ end
34
+ end
35
+
36
+ class Pair
37
+ SECURITY_CAPABILITY_MAP = {
38
+ "low" => "NoInputNoOutput",
39
+ "medium" => "DisplayYesNo",
40
+ "high" => "KeyboardDisplay"
41
+ }.freeze
42
+
43
+ def initialize(options)
44
+ @options = options
45
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
46
+ @address = options["address"]
47
+ @timeout = options["timeout"] || 30
48
+ @security = options["security"]
49
+ @force = options["force"] || false
50
+ end
51
+
52
+ def execute
53
+ if @security && !SECURITY_CAPABILITY_MAP.key?(@security)
54
+ $stderr.puts "Error: Invalid security level '#{@security}'. Use: #{SECURITY_CAPABILITY_MAP.keys.join(', ')}"
55
+ exit 1
56
+ end
57
+
58
+ backend = RBLE::Backend.for_platform
59
+ device_path = backend.device_path_for_address(@address)
60
+
61
+ if device_path.nil?
62
+ $stderr.puts "Error: Device #{@address} not found."
63
+ $stderr.puts "Run `rble scan` first to discover nearby devices."
64
+ exit 1
65
+ end
66
+
67
+ io_handler = @options["json"] ? NullIOHandler.new : TerminalIOHandler.new
68
+ capability = SECURITY_CAPABILITY_MAP[@security]
69
+
70
+ $stderr.puts "Pairing with #{@address}..."
71
+ result = backend.pair_device(
72
+ device_path,
73
+ io_handler: io_handler,
74
+ force: @force,
75
+ capability: capability,
76
+ timeout: @timeout
77
+ )
78
+
79
+ case result
80
+ when :paired
81
+ $stderr.puts "Bonded successfully"
82
+ @formatter.pair_result(success: true, address: @address, message: "Bonded successfully")
83
+ when :already_paired
84
+ $stderr.puts "Device already paired"
85
+ @formatter.pair_result(success: true, address: @address, message: "Already paired")
86
+ end
87
+ rescue RBLE::DeviceNotFoundError => e
88
+ $stderr.puts "Error: #{e.message}"
89
+ $stderr.puts "Run `rble scan` first to discover nearby devices."
90
+ exit 1
91
+ rescue RBLE::AuthenticationError
92
+ $stderr.puts "Error: Pairing rejected by device. Ensure device is in pairing mode and try again."
93
+ exit 1
94
+ rescue RBLE::ConnectionFailed
95
+ $stderr.puts "Error: Connection failed. Device may be out of range."
96
+ exit 1
97
+ rescue RBLE::Error => e
98
+ $stderr.puts "Error: #{e.message}"
99
+ exit 1
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module CLI
5
+ class Paired
6
+ def initialize(options)
7
+ @options = options
8
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
9
+ end
10
+
11
+ def execute
12
+ backend = RBLE::Backend.for_platform
13
+ devices = backend.bonded_devices
14
+
15
+ @formatter.paired_list(devices)
16
+ rescue RBLE::Error => e
17
+ $stderr.puts "Error: #{e.message}"
18
+ exit 1
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'characteristic_helpers'
4
+
5
+ module RBLE
6
+ module CLI
7
+ class Read
8
+ include CharacteristicHelpers
9
+
10
+ def initialize(options)
11
+ @options = options
12
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
13
+ @address = options["address"]
14
+ @characteristic_uuid = options["characteristic"]
15
+ @timeout = options["timeout"] || 10
16
+ end
17
+
18
+ def execute
19
+ connection = connect_and_discover(@address, timeout: @timeout)
20
+
21
+ begin
22
+ char = find_characteristic(connection, @characteristic_uuid)
23
+
24
+ unless char.readable?
25
+ $stderr.puts "Error: Characteristic #{@characteristic_uuid} does not support read."
26
+ $stderr.puts "Supported: #{char.flags.join(', ')}"
27
+ exit 1
28
+ end
29
+
30
+ name = resolve_char_name(char.uuid)
31
+ $stderr.puts "Reading #{name}..."
32
+ raw_value = char.read
33
+ formatted = format_value(char.uuid, raw_value)
34
+
35
+ @formatter.read_value(
36
+ address: @address,
37
+ uuid: char.uuid,
38
+ name: RBLE::GATT::UUIDDatabase.resolve(char.uuid, type: :characteristic),
39
+ raw: raw_value,
40
+ formatted: formatted
41
+ )
42
+ ensure
43
+ connection.disconnect if connection.connected?
44
+ end
45
+ rescue RBLE::DeviceNotFoundError => e
46
+ $stderr.puts "Error: #{e.message}"
47
+ $stderr.puts "Run `rble scan` first to discover nearby devices."
48
+ exit 1
49
+ rescue RBLE::Error => e
50
+ $stderr.puts "Error: #{e.message}"
51
+ exit 1
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module RBLE
6
+ module CLI
7
+ class Scan
8
+ def initialize(options)
9
+ @options = options
10
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
11
+ @advertisement_count = 0
12
+ @seen_addresses = Set.new
13
+ @start_time = nil
14
+ end
15
+
16
+ def execute
17
+ scanner = RBLE::Scanner.new(
18
+ timeout: @options["timeout"],
19
+ active: !@options["passive"],
20
+ allow_duplicates: true
21
+ )
22
+
23
+ prev_handler = trap("INT") { scanner.stop }
24
+
25
+ @advertisement_count = 0
26
+ @seen_addresses = Set.new
27
+ @start_time = Time.now
28
+
29
+ begin
30
+ scanner.start do |device|
31
+ next unless matches_filters?(device)
32
+
33
+ @advertisement_count += 1
34
+ is_new = @seen_addresses.add?(device.address)
35
+ next if @options["unique"] && !is_new
36
+
37
+ @formatter.device(device)
38
+ end
39
+ rescue RBLE::Error => e
40
+ handle_error(e)
41
+ ensure
42
+ trap("INT", prev_handler || "DEFAULT")
43
+ end
44
+
45
+ duration = (Time.now - @start_time).round(1)
46
+ device_count = @seen_addresses.size
47
+ $stderr.puts "Discovered #{device_count} device#{'s' unless device_count == 1} " \
48
+ "(#{@advertisement_count} advertisement#{'s' unless @advertisement_count == 1}) " \
49
+ "in #{duration}s"
50
+
51
+ if @options["timeout"] && @advertisement_count.zero? && has_filters?
52
+ $stderr.puts "No devices found matching filters"
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def matches_filters?(device)
59
+ if @options["name"]
60
+ return false unless device.name
61
+ return false unless device.name.downcase.include?(@options["name"].downcase)
62
+ end
63
+
64
+ if @options["rssi"]
65
+ return false unless device.rssi
66
+ return false unless device.rssi >= @options["rssi"]
67
+ end
68
+
69
+ true
70
+ end
71
+
72
+ def has_filters?
73
+ @options["name"] || @options["rssi"]
74
+ end
75
+
76
+ def handle_error(error)
77
+ $stderr.puts "Error: #{error.message}"
78
+
79
+ if @options["verbose"]
80
+ $stderr.puts error.recovery_hint if error.respond_to?(:recovery_hint)
81
+ $stderr.puts error.backtrace.join("\n") if error.backtrace
82
+ end
83
+
84
+ exit 1
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rble/gatt/uuid_database'
4
+ require_relative 'characteristic_helpers'
5
+
6
+ module RBLE
7
+ module CLI
8
+ class Show
9
+ include CharacteristicHelpers
10
+
11
+ def initialize(options)
12
+ @options = options
13
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
14
+ @address = options["address"]
15
+ @timeout = options["timeout"] || 10
16
+ end
17
+
18
+ def execute
19
+ backend = RBLE::Backend.for_platform
20
+ connection = connect_with_retry(@address, timeout: @timeout)
21
+
22
+ begin
23
+ $stderr.puts "Discovering services..."
24
+ connection.discover_services
25
+ services = connection.services
26
+
27
+ $stderr.puts "Found #{services.size} service#{'s' unless services.size == 1}"
28
+
29
+ tree_data = build_tree_data(@address, connection, services, backend)
30
+ @formatter.show_tree(tree_data)
31
+ ensure
32
+ connection.disconnect if connection.connected?
33
+ end
34
+ rescue RBLE::DeviceNotFoundError => e
35
+ $stderr.puts "Error: #{e.message}"
36
+ $stderr.puts "Run `rble scan` first to discover nearby devices."
37
+ exit 1
38
+ rescue RBLE::Error => e
39
+ $stderr.puts "Error: #{e.message}"
40
+ exit 1
41
+ end
42
+
43
+ private
44
+
45
+ def build_tree_data(address, connection, services, backend)
46
+ device_name = begin
47
+ backend.device_name(connection.device_path)
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ service_data = services.map do |service|
53
+ resolved_service = RBLE::GATT::UUIDDatabase.resolve(service.uuid, type: :service)
54
+
55
+ chars = service.characteristics.map do |char|
56
+ resolved_char = RBLE::GATT::UUIDDatabase.resolve(char.uuid, type: :characteristic)
57
+
58
+ descriptors = extract_descriptors(char)
59
+
60
+ {
61
+ uuid: char.uuid,
62
+ resolved_name: resolved_char,
63
+ properties: char.flags,
64
+ handle: extract_handle(char.path),
65
+ descriptors: descriptors
66
+ }
67
+ end
68
+
69
+ {
70
+ uuid: service.uuid,
71
+ resolved_name: resolved_service,
72
+ primary: service.primary,
73
+ characteristics: chars
74
+ }
75
+ end
76
+
77
+ total_chars = service_data.sum { |s| s[:characteristics].size }
78
+
79
+ {
80
+ address: address,
81
+ name: device_name,
82
+ services: service_data,
83
+ total_characteristics: total_chars
84
+ }
85
+ end
86
+
87
+ def extract_descriptors(char)
88
+ return [] unless char.respond_to?(:descriptors) && char.descriptors
89
+
90
+ char.descriptors.map do |desc|
91
+ resolved_desc = RBLE::GATT::UUIDDatabase.resolve(desc[:uuid], type: :descriptor)
92
+ {
93
+ uuid: desc[:uuid],
94
+ resolved_name: resolved_desc,
95
+ handle: extract_handle(desc[:path])
96
+ }
97
+ end
98
+ end
99
+
100
+ def extract_handle(path)
101
+ return nil unless path
102
+
103
+ if path =~ /([0-9a-fA-F]{4})\z/
104
+ "0x#{Regexp.last_match(1).upcase}"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module CLI
5
+ class Status
6
+ def initialize(options)
7
+ @options = options
8
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
9
+ end
10
+
11
+ def execute
12
+ backend = Backend.for_platform
13
+ adapter_name = @options["adapter"] || backend.default_adapter_name
14
+ info = backend.adapter_info(adapter_name)
15
+ @formatter.status(info)
16
+ rescue RBLE::AdapterNotFoundError
17
+ $stderr.puts "No Bluetooth adapter found. Run `rble doctor` to diagnose."
18
+ exit 1
19
+ rescue RBLE::Error => e
20
+ $stderr.puts "Error: #{e.message}"
21
+ exit 1
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module CLI
5
+ class Unpair
6
+ def initialize(options)
7
+ @options = options
8
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
9
+ @address = options["address"]
10
+ end
11
+
12
+ def execute
13
+ backend = RBLE::Backend.for_platform
14
+ device_path = backend.device_path_for_address(@address)
15
+
16
+ if device_path.nil?
17
+ $stderr.puts "Device not paired"
18
+ @formatter.pair_result(success: true, address: @address, message: "Not paired")
19
+ return
20
+ end
21
+
22
+ $stderr.puts "Removing pairing for #{@address}..."
23
+ result = backend.unpair_device(device_path)
24
+
25
+ case result
26
+ when :unpaired
27
+ $stderr.puts "Pairing removed"
28
+ @formatter.pair_result(success: true, address: @address, message: "Pairing removed")
29
+ when :not_paired
30
+ $stderr.puts "Device not paired"
31
+ @formatter.pair_result(success: true, address: @address, message: "Not paired")
32
+ end
33
+ rescue RBLE::Error => e
34
+ $stderr.puts "Error: #{e.message}"
35
+ exit 1
36
+ end
37
+ end
38
+ end
39
+ end