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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ module CLI
5
+ # Smart parser registry for known BLE characteristic data types.
6
+ #
7
+ # Converts raw binary characteristic values into human-readable strings
8
+ # with appropriate units and formatting. Unknown UUIDs return nil,
9
+ # allowing callers to fall back to hex dump display.
10
+ #
11
+ # @example Parse a known characteristic
12
+ # ValueParser.parse("2a19", "\x5F".b) # => "95%"
13
+ # ValueParser.parse("2a37", "\x00\x48".b) # => "72 bpm"
14
+ #
15
+ # @example Check if a UUID has a parser
16
+ # ValueParser.known?("2a19") # => true
17
+ # ValueParser.known?("ffff") # => false
18
+ module ValueParser
19
+ # Body Sensor Location enum (Bluetooth SIG)
20
+ BODY_SENSOR_LOCATIONS = {
21
+ 0 => "Other",
22
+ 1 => "Chest",
23
+ 2 => "Wrist",
24
+ 3 => "Finger",
25
+ 4 => "Hand",
26
+ 5 => "Ear Lobe",
27
+ 6 => "Foot"
28
+ }.freeze
29
+
30
+ # Registry of parsers keyed by short 4-char characteristic UUID (lowercase).
31
+ # Each parser is a lambda: (raw_bytes_string) -> formatted_string
32
+ PARSERS = {
33
+ # Battery Level: uint8, percentage
34
+ "2a19" => ->(raw) {
35
+ return "malformed" if raw.bytesize < 1
36
+ "#{raw.unpack1('C')}%"
37
+ },
38
+
39
+ # Heart Rate Measurement: flags + uint8/uint16
40
+ "2a37" => ->(raw) {
41
+ return "malformed" if raw.bytesize < 2
42
+ flags = raw.getbyte(0)
43
+ hr_16bit = (flags & 0x01) == 1
44
+ if hr_16bit
45
+ return "malformed" if raw.bytesize < 3
46
+ hr = raw[1, 2].unpack1('S<')
47
+ else
48
+ hr = raw.getbyte(1)
49
+ end
50
+ "#{hr} bpm"
51
+ },
52
+
53
+ # Temperature (Environmental Sensing): sint16 LE / 100
54
+ "2a6e" => ->(raw) {
55
+ return "malformed" if raw.bytesize < 2
56
+ raw_val = raw.unpack1('s<')
57
+ temp = raw_val / 100.0
58
+ format('%.2f C', temp)
59
+ },
60
+
61
+ # Temperature Measurement (Health Thermometer): flags + IEEE 11073 FLOAT
62
+ "2a1c" => ->(raw) {
63
+ return "malformed" if raw.bytesize < 5
64
+ flags = raw.getbyte(0)
65
+ unit = (flags & 0x01) == 0 ? 'C' : 'F'
66
+ temp = ieee_11073_float(raw[1, 4])
67
+ format('%.1f %s', temp, unit)
68
+ },
69
+
70
+ # Tx Power Level: sint8, dBm
71
+ "2a07" => ->(raw) {
72
+ return "malformed" if raw.bytesize < 1
73
+ "#{raw.unpack1('c')} dBm"
74
+ },
75
+
76
+ # Blood Pressure Measurement: flags + 3x SFLOAT
77
+ "2a35" => ->(raw) {
78
+ return "malformed" if raw.bytesize < 5
79
+ flags = raw.getbyte(0)
80
+ unit = (flags & 0x01) == 0 ? 'mmHg' : 'kPa'
81
+ systolic = ieee_11073_sfloat(raw[1, 2])
82
+ diastolic = ieee_11073_sfloat(raw[3, 2])
83
+ format('%.0f/%.0f %s', systolic, diastolic, unit)
84
+ },
85
+
86
+ # Humidity: uint16 LE / 100
87
+ "2a6f" => ->(raw) {
88
+ return "malformed" if raw.bytesize < 2
89
+ raw_val = raw.unpack1('S<')
90
+ humidity = raw_val / 100.0
91
+ format('%.2f%%', humidity)
92
+ },
93
+
94
+ # Device Name: UTF-8 string
95
+ "2a00" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
96
+
97
+ # Model Number String: UTF-8 string
98
+ "2a24" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
99
+
100
+ # Serial Number String: UTF-8 string
101
+ "2a25" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
102
+
103
+ # Firmware Revision String: UTF-8 string
104
+ "2a26" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
105
+
106
+ # Hardware Revision String: UTF-8 string
107
+ "2a27" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
108
+
109
+ # Software Revision String: UTF-8 string
110
+ "2a28" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
111
+
112
+ # Manufacturer Name String: UTF-8 string
113
+ "2a29" => ->(raw) { raw.force_encoding('UTF-8').scrub('?').strip },
114
+
115
+ # Body Sensor Location: uint8 enum
116
+ "2a38" => ->(raw) {
117
+ return "malformed" if raw.bytesize < 1
118
+ val = raw.unpack1('C')
119
+ BODY_SENSOR_LOCATIONS.fetch(val, "Location: #{val}")
120
+ }
121
+ }.freeze
122
+
123
+ # Parse raw characteristic bytes into a human-readable string.
124
+ #
125
+ # @param uuid [String] Characteristic UUID (short, full, or with 0x prefix)
126
+ # @param raw_bytes [String] Binary string of raw characteristic data
127
+ # @return [String, nil] Formatted value string, or nil if UUID is unknown
128
+ def self.parse(uuid, raw_bytes)
129
+ key = normalize_key(uuid)
130
+ PARSERS[key]&.call(raw_bytes)
131
+ end
132
+
133
+ # Check if a UUID has a registered parser.
134
+ #
135
+ # @param uuid [String] Characteristic UUID
136
+ # @return [Boolean]
137
+ def self.known?(uuid)
138
+ key = normalize_key(uuid)
139
+ PARSERS.key?(key)
140
+ end
141
+
142
+ # Parse IEEE 11073 32-bit FLOAT.
143
+ # Format: 8-bit exponent (signed) + 24-bit mantissa (signed)
144
+ # Value = mantissa * 10^exponent
145
+ #
146
+ # @param bytes [String] 4-byte binary string (little-endian)
147
+ # @return [Float] Parsed value
148
+ def self.ieee_11073_float(bytes)
149
+ return Float::NAN if bytes.nil? || bytes.bytesize < 4
150
+
151
+ # Little-endian: bytes[0..2] = mantissa (24-bit), bytes[3] = exponent (8-bit)
152
+ # Pad to 4 bytes for uint32 unpack, then mask to 24 bits
153
+ mantissa = (bytes[0, 3] + "\x00".b).unpack1('V') & 0x00FFFFFF
154
+ # Sign-extend 24-bit to Ruby integer
155
+ mantissa -= 0x1000000 if mantissa >= 0x800000
156
+ exponent = bytes.getbyte(3)
157
+ exponent -= 256 if exponent >= 128 # sign-extend 8-bit
158
+
159
+ # Special values
160
+ return Float::NAN if mantissa == 0x7FFFFF && exponent == 0
161
+ return Float::INFINITY if mantissa == 0x7FFFFE && exponent == 0
162
+ return -Float::INFINITY if mantissa == -0x7FFFFE && exponent == 0
163
+
164
+ mantissa * (10.0**exponent)
165
+ end
166
+
167
+ # Parse IEEE 11073 16-bit SFLOAT.
168
+ # Format: 4-bit exponent (signed) + 12-bit mantissa (signed)
169
+ # Value = mantissa * 10^exponent
170
+ #
171
+ # @param bytes [String] 2-byte binary string (little-endian)
172
+ # @return [Float] Parsed value
173
+ def self.ieee_11073_sfloat(bytes)
174
+ return Float::NAN if bytes.nil? || bytes.bytesize < 2
175
+
176
+ raw = bytes.unpack1('S<')
177
+ mantissa = raw & 0x0FFF
178
+ exponent = (raw >> 12) & 0x0F
179
+
180
+ # Sign-extend
181
+ mantissa -= 0x1000 if mantissa >= 0x800
182
+ exponent -= 16 if exponent >= 8
183
+
184
+ # Special values
185
+ return Float::NAN if mantissa == 0x7FF && exponent == 0
186
+ return Float::INFINITY if mantissa == 0x7FE && exponent == 0
187
+ return -Float::INFINITY if mantissa == -0x7FE && exponent == 0
188
+
189
+ mantissa * (10.0**exponent)
190
+ end
191
+
192
+ # Normalize a UUID input to a short 4-char key for PARSERS lookup.
193
+ # Handles: "2a19", "2A19", "0x2a19", "00002a19-0000-1000-8000-00805f9b34fb"
194
+ #
195
+ # @param uuid [String] UUID input
196
+ # @return [String] Normalized short key (lowercase)
197
+ def self.normalize_key(uuid)
198
+ key = uuid.downcase.sub(/\A0x/, '')
199
+ if key =~ /\A0000([0-9a-f]{4})-0000-1000-8000-00805f9b34fb\z/
200
+ Regexp.last_match(1)
201
+ elsif key =~ /\A0000([0-9a-f]{4})\z/
202
+ Regexp.last_match(1)
203
+ else
204
+ key
205
+ end
206
+ end
207
+
208
+ private_class_method :normalize_key
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'characteristic_helpers'
4
+
5
+ module RBLE
6
+ module CLI
7
+ class Write
8
+ include CharacteristicHelpers
9
+
10
+ # Type flag names -> encoder method symbols
11
+ ENCODERS = {
12
+ "hex" => :encode_hex,
13
+ "string" => :encode_string,
14
+ "uint8" => :encode_uint8,
15
+ "uint16" => :encode_uint16,
16
+ "uint32" => :encode_uint32,
17
+ "int8" => :encode_int8,
18
+ "int16" => :encode_int16,
19
+ "int32" => :encode_int32
20
+ }.freeze
21
+
22
+ # Range limits for integer types
23
+ RANGES = {
24
+ uint8: 0..255,
25
+ uint16: 0..65_535,
26
+ uint32: 0..4_294_967_295,
27
+ int8: -128..127,
28
+ int16: -32_768..32_767,
29
+ int32: -2_147_483_648..2_147_483_647
30
+ }.freeze
31
+
32
+ def initialize(options)
33
+ @options = options
34
+ @formatter = options["json"] ? Formatters::Json.new : Formatters::Text.new
35
+ @address = options["address"]
36
+ @characteristic_uuid = options["characteristic"]
37
+ @value = options["value"]
38
+ @timeout = options["timeout"] || 10
39
+ @verify = options["verify"] || false
40
+ end
41
+
42
+ def execute
43
+ type_flag = detect_type_flag
44
+ encoded = encode(@value, type_flag)
45
+
46
+ connection = connect_and_discover(@address, timeout: @timeout)
47
+
48
+ begin
49
+ char = find_characteristic(connection, @characteristic_uuid)
50
+
51
+ # Auto-detect write mode: prefer write-with-response, fall back to write-without-response
52
+ response = if char.writable?
53
+ true
54
+ elsif char.writable_without_response?
55
+ false
56
+ else
57
+ $stderr.puts "Error: Characteristic #{@characteristic_uuid} does not support write."
58
+ $stderr.puts "Supported: #{char.flags.join(', ')}"
59
+ exit 1
60
+ end
61
+
62
+ name = resolve_char_name(char.uuid)
63
+ $stderr.puts "Writing to #{name}..."
64
+ char.write(encoded, response: response)
65
+ $stderr.puts "Write successful"
66
+
67
+ verified_value = nil
68
+ if @verify
69
+ if char.readable?
70
+ $stderr.puts "Verifying..."
71
+ raw_readback = char.read
72
+ verified_value = format_value(char.uuid, raw_readback)
73
+ $stderr.puts "Verified: #{verified_value}"
74
+ else
75
+ $stderr.puts "Warning: Cannot verify -- characteristic is not readable"
76
+ end
77
+ end
78
+
79
+ char_name = RBLE::GATT::UUIDDatabase.resolve(char.uuid, type: :characteristic)
80
+ @formatter.write_result(
81
+ address: @address,
82
+ uuid: char.uuid,
83
+ name: char_name,
84
+ success: true,
85
+ verified: verified_value,
86
+ written_bytes: encoded
87
+ )
88
+ ensure
89
+ connection.disconnect if connection.connected?
90
+ end
91
+ rescue RBLE::DeviceNotFoundError => e
92
+ $stderr.puts "Error: #{e.message}"
93
+ $stderr.puts "Run `rble scan` first to discover nearby devices."
94
+ exit 1
95
+ rescue RBLE::Error => e
96
+ $stderr.puts "Error: #{e.message}"
97
+ exit 1
98
+ end
99
+
100
+ private
101
+
102
+ # Detect which type flag the user set. Exactly one must be present.
103
+ # @return [String] The type flag name ("hex", "string", "uint8", etc.)
104
+ # @raise [SystemExit] if zero or multiple type flags set
105
+ def detect_type_flag
106
+ active = ENCODERS.keys.select { |flag| @options[flag] }
107
+
108
+ if active.empty?
109
+ $stderr.puts "Error: Exactly one type flag required: --hex, --string, --uint8, --uint16, --uint32, --int8, --int16, --int32"
110
+ exit 1
111
+ elsif active.size > 1
112
+ $stderr.puts "Error: Only one type flag allowed, got: #{active.map { |f| "--#{f}" }.join(', ')}"
113
+ exit 1
114
+ end
115
+
116
+ active.first
117
+ end
118
+
119
+ # Encode value string based on type flag.
120
+ # @param value_string [String] User-provided value
121
+ # @param type_flag [String] Type flag name
122
+ # @return [String] Encoded binary string
123
+ def encode(value_string, type_flag)
124
+ send(ENCODERS[type_flag], value_string)
125
+ end
126
+
127
+ def encode_hex(value)
128
+ hex = value.sub(/\A0x/i, '')
129
+ unless hex.match?(/\A[0-9a-fA-F]+\z/)
130
+ $stderr.puts "Error: Invalid hex string: '#{value}'. Expected hex digits (e.g., '0A1BFF')"
131
+ exit 1
132
+ end
133
+ unless hex.length.even?
134
+ $stderr.puts "Error: Hex string must have even length, got #{hex.length} characters"
135
+ exit 1
136
+ end
137
+ [hex].pack('H*')
138
+ end
139
+
140
+ def encode_string(value)
141
+ value.encode('UTF-8')
142
+ end
143
+
144
+ def encode_uint8(value)
145
+ v = parse_integer(value)
146
+ validate_range(v, :uint8)
147
+ [v].pack('C')
148
+ end
149
+
150
+ def encode_uint16(value)
151
+ v = parse_integer(value)
152
+ validate_range(v, :uint16)
153
+ [v].pack('S<')
154
+ end
155
+
156
+ def encode_uint32(value)
157
+ v = parse_integer(value)
158
+ validate_range(v, :uint32)
159
+ [v].pack('L<')
160
+ end
161
+
162
+ def encode_int8(value)
163
+ v = parse_integer(value)
164
+ validate_range(v, :int8)
165
+ [v].pack('c')
166
+ end
167
+
168
+ def encode_int16(value)
169
+ v = parse_integer(value)
170
+ validate_range(v, :int16)
171
+ [v].pack('s<')
172
+ end
173
+
174
+ def encode_int32(value)
175
+ v = parse_integer(value)
176
+ validate_range(v, :int32)
177
+ [v].pack('l<')
178
+ end
179
+
180
+ def parse_integer(value)
181
+ Integer(value, 10)
182
+ rescue ArgumentError
183
+ $stderr.puts "Error: Invalid integer: '#{value}'. Use decimal digits only."
184
+ exit 1
185
+ end
186
+
187
+ def validate_range(v, type)
188
+ range = RANGES[type]
189
+ return if range.include?(v)
190
+
191
+ $stderr.puts "Error: Value #{v} out of range for #{type} (#{range.min} to #{range.max})"
192
+ exit 1
193
+ end
194
+ end
195
+ end
196
+ end
data/lib/rble/cli.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'rble'
5
+ require_relative 'cli/formatters/text'
6
+ require_relative 'cli/formatters/json'
7
+ require_relative 'cli/adapter'
8
+
9
+ module RBLE
10
+ module CLI
11
+ class Main < Thor
12
+ package_name "rble"
13
+
14
+ def self.namespace
15
+ "rble"
16
+ end
17
+
18
+ def self.exit_on_failure?
19
+ true
20
+ end
21
+
22
+ check_unknown_options!
23
+
24
+ class_option :json, type: :boolean, default: false,
25
+ desc: "Output as JSON (NDJSON for streaming)"
26
+ class_option :verbose, type: :boolean, aliases: "-v", default: false,
27
+ desc: "Show detailed error information"
28
+
29
+ desc "scan", "Discover nearby BLE devices"
30
+ method_option :timeout, type: :numeric, aliases: "-t",
31
+ desc: "Stop after N seconds (default: continuous)"
32
+ method_option :name, type: :string, aliases: "-n",
33
+ desc: "Filter by device name (case-insensitive substring)"
34
+ method_option :rssi, type: :numeric, aliases: "-r",
35
+ desc: "Minimum RSSI threshold (e.g., -70)"
36
+ method_option :passive, type: :boolean, default: false,
37
+ desc: "Use passive scanning (no scan requests sent)"
38
+ method_option :unique, type: :boolean, aliases: "-u", default: false,
39
+ desc: "Show each device only once (suppress duplicate advertisements)"
40
+ def scan
41
+ require_relative 'cli/scan'
42
+ Scan.new(options).execute
43
+ end
44
+
45
+ desc "show ADDRESS", "Display GATT services and characteristics"
46
+ method_option :timeout, type: :numeric, aliases: "-t", default: 10,
47
+ desc: "Connection timeout in seconds"
48
+ def show(address)
49
+ require_relative 'cli/show'
50
+ merged = options.merge("address" => address)
51
+ Show.new(merged).execute
52
+ end
53
+
54
+ desc "pair ADDRESS", "Pair with a BLE device"
55
+ method_option :timeout, type: :numeric, aliases: "-t", default: 30,
56
+ desc: "Pairing timeout in seconds"
57
+ method_option :security, type: :string, aliases: "-s",
58
+ desc: "Security level (low, medium, high)"
59
+ method_option :force, type: :boolean, default: false,
60
+ desc: "Auto-accept pairing prompts"
61
+ def pair(address)
62
+ require_relative 'cli/pair'
63
+ merged = options.merge("address" => address)
64
+ Pair.new(merged).execute
65
+ end
66
+
67
+ desc "unpair ADDRESS", "Remove pairing bond from a device"
68
+ def unpair(address)
69
+ require_relative 'cli/unpair'
70
+ merged = options.merge("address" => address)
71
+ Unpair.new(merged).execute
72
+ end
73
+
74
+ desc "paired", "List bonded devices"
75
+ def paired
76
+ require_relative 'cli/paired'
77
+ Paired.new(options).execute
78
+ end
79
+
80
+ desc "read ADDRESS CHARACTERISTIC", "Read a characteristic value"
81
+ method_option :timeout, type: :numeric, aliases: "-t", default: 10,
82
+ desc: "Connection timeout in seconds"
83
+ def read(address, characteristic)
84
+ require_relative 'cli/read'
85
+ merged = options.merge("address" => address, "characteristic" => characteristic)
86
+ Read.new(merged).execute
87
+ end
88
+
89
+ desc "write ADDRESS CHARACTERISTIC VALUE", "Write a value to a characteristic"
90
+ method_option :timeout, type: :numeric, aliases: "-t", default: 10,
91
+ desc: "Connection timeout in seconds"
92
+ method_option :hex, type: :boolean, default: false,
93
+ desc: "Value is hex bytes (e.g., '0A1BFF')"
94
+ method_option :string, type: :boolean, default: false,
95
+ desc: "Value is UTF-8 string"
96
+ method_option :uint8, type: :boolean, default: false,
97
+ desc: "Value is unsigned 8-bit integer"
98
+ method_option :uint16, type: :boolean, default: false,
99
+ desc: "Value is unsigned 16-bit integer (LE)"
100
+ method_option :uint32, type: :boolean, default: false,
101
+ desc: "Value is unsigned 32-bit integer (LE)"
102
+ method_option :int8, type: :boolean, default: false,
103
+ desc: "Value is signed 8-bit integer"
104
+ method_option :int16, type: :boolean, default: false,
105
+ desc: "Value is signed 16-bit integer (LE)"
106
+ method_option :int32, type: :boolean, default: false,
107
+ desc: "Value is signed 32-bit integer (LE)"
108
+ method_option :verify, type: :boolean, default: false,
109
+ desc: "Read back after writing to confirm"
110
+ def write(address, characteristic, value)
111
+ require_relative 'cli/write'
112
+ merged = options.merge("address" => address, "characteristic" => characteristic, "value" => value)
113
+ Write.new(merged).execute
114
+ end
115
+
116
+ desc "monitor ADDRESS CHARACTERISTIC", "Subscribe to characteristic notifications"
117
+ method_option :timeout, type: :numeric, aliases: "-t", default: 10,
118
+ desc: "Connection timeout in seconds"
119
+ method_option :reconnect, type: :boolean, default: false,
120
+ desc: "Auto-reconnect on disconnect"
121
+ def monitor(address, characteristic)
122
+ require_relative 'cli/monitor'
123
+ merged = options.merge("address" => address, "characteristic" => characteristic)
124
+ Monitor.new(merged).execute
125
+ end
126
+
127
+ desc "status", "Show Bluetooth adapter status"
128
+ method_option :adapter, type: :string, desc: "Adapter name (default: auto)"
129
+ def status
130
+ require_relative 'cli/status'
131
+ Status.new(options).execute
132
+ end
133
+
134
+ desc "doctor", "Diagnose Bluetooth problems"
135
+ def doctor
136
+ require_relative 'cli/doctor'
137
+ Doctor.new(options).execute
138
+ end
139
+
140
+ desc "adapter SUBCOMMAND", "Manage Bluetooth adapters"
141
+ subcommand "adapter", AdapterCli
142
+
143
+ desc "version", "Show rble version"
144
+ def version
145
+ puts "rble #{RBLE::VERSION}"
146
+ end
147
+
148
+ map "--version" => :version
149
+ map "-V" => :version
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBLE
4
+ # Bluetooth SIG assigned Company Identifiers.
5
+ # Used to identify device manufacturers from BLE advertisement data
6
+ # when no device name is broadcast.
7
+ #
8
+ # Loaded on-demand (not auto-required by lib/rble.rb).
9
+ # Use: require 'rble/company_ids'
10
+ #
11
+ # Source: Bluetooth SIG Assigned Numbers
12
+ # https://www.bluetooth.com/specifications/assigned-numbers/
13
+ #
14
+ # @example Resolve a company ID
15
+ # RBLE::CompanyIdentifiers.resolve(0x004C) # => "Apple"
16
+ module CompanyIdentifiers
17
+ # Curated subset of ~80 common BLE device/chipset manufacturers.
18
+ # Keys are 16-bit Company ID integers, values are short display names.
19
+ COMPANIES = {
20
+ 0x0000 => "Ericsson",
21
+ 0x0001 => "Nokia",
22
+ 0x0002 => "Intel",
23
+ 0x0004 => "Toshiba",
24
+ 0x0006 => "Microsoft",
25
+ 0x0008 => "Motorola",
26
+ 0x000A => "Qualcomm",
27
+ 0x000D => "Texas Instruments",
28
+ 0x000F => "Broadcom",
29
+ 0x0022 => "NEC",
30
+ 0x0025 => "NXP",
31
+ 0x0030 => "ST Microelectronics",
32
+ 0x0034 => "Renesas",
33
+ 0x003A => "Panasonic",
34
+ 0x003C => "BlackBerry",
35
+ 0x0040 => "Seiko Epson",
36
+ 0x0043 => "Parrot",
37
+ 0x0046 => "MediaTek",
38
+ 0x0047 => "Bluegiga",
39
+ 0x0048 => "Marvell",
40
+ 0x004C => "Apple",
41
+ 0x004E => "Avago",
42
+ 0x0055 => "Plantronics",
43
+ 0x0057 => "Harman",
44
+ 0x0058 => "Vizio",
45
+ 0x0059 => "Nordic Semiconductor",
46
+ 0x005D => "Realtek",
47
+ 0x0065 => "HP",
48
+ 0x006B => "Polar",
49
+ 0x0075 => "Samsung",
50
+ 0x0076 => "Creative",
51
+ 0x0078 => "Nike",
52
+ 0x0087 => "Garmin",
53
+ 0x0094 => "Airoha",
54
+ 0x00C3 => "Adidas",
55
+ 0x00C4 => "LG Electronics",
56
+ 0x00E0 => "Google",
57
+ 0x0131 => "Cypress Semiconductor",
58
+ 0x0171 => "Amazon",
59
+ 0x018E => "Google",
60
+ 0x01AB => "Meta",
61
+ 0x01DA => "Logitech",
62
+ 0x01DD => "Philips",
63
+ 0x01D1 => "August Home",
64
+ 0x01FC => "Wahoo Fitness",
65
+ 0x027D => "Huawei",
66
+ 0x02E5 => "Espressif",
67
+ 0x02FF => "Silicon Labs",
68
+ 0x038F => "Xiaomi",
69
+ 0x03C1 => "Ember Technologies",
70
+ 0x03FF => "Withings",
71
+ 0x040F => "OnePlus",
72
+ 0x0499 => "Ruuvi Innovations",
73
+ 0x05A7 => "Sonos",
74
+ 0x060F => "Signify",
75
+ 0x067C => "Tile",
76
+ 0x0768 => "Peloton",
77
+ 0x07D0 => "Tuya",
78
+ 0x07D6 => "Ecobee",
79
+ 0x079A => "Roku",
80
+ }.freeze
81
+
82
+ # Resolve a company identifier to a human-readable name.
83
+ #
84
+ # @param company_id [Integer] 16-bit Bluetooth SIG Company Identifier
85
+ # @return [String, nil] Company name, or nil if unknown
86
+ def self.resolve(company_id)
87
+ COMPANIES[company_id]
88
+ end
89
+ end
90
+ end