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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +169 -0
- data/LICENSE.txt +21 -0
- data/README.md +514 -0
- data/exe/rble +14 -0
- data/ext/macos_ble/Package.swift +20 -0
- data/ext/macos_ble/Sources/RBLEHelper/BLEManager.swift +783 -0
- data/ext/macos_ble/Sources/RBLEHelper/Protocol.swift +173 -0
- data/ext/macos_ble/Sources/RBLEHelper/main.swift +645 -0
- data/ext/macos_ble/extconf.rb +73 -0
- data/lib/rble/backend/base.rb +181 -0
- data/lib/rble/backend/bluez.rb +1279 -0
- data/lib/rble/backend/corebluetooth.rb +653 -0
- data/lib/rble/backend.rb +193 -0
- data/lib/rble/bluez/adapter.rb +169 -0
- data/lib/rble/bluez/async_call.rb +85 -0
- data/lib/rble/bluez/async_connection_operations.rb +492 -0
- data/lib/rble/bluez/async_gatt_operations.rb +249 -0
- data/lib/rble/bluez/async_introspection.rb +151 -0
- data/lib/rble/bluez/dbus_connection.rb +64 -0
- data/lib/rble/bluez/dbus_session.rb +344 -0
- data/lib/rble/bluez/device.rb +86 -0
- data/lib/rble/bluez/event_loop.rb +153 -0
- data/lib/rble/bluez/gatt_operation_queue.rb +129 -0
- data/lib/rble/bluez/pairing_agent.rb +132 -0
- data/lib/rble/bluez/pairing_session.rb +212 -0
- data/lib/rble/bluez/retry_policy.rb +55 -0
- data/lib/rble/bluez.rb +33 -0
- data/lib/rble/characteristic.rb +237 -0
- data/lib/rble/cli/adapter.rb +88 -0
- data/lib/rble/cli/characteristic_helpers.rb +154 -0
- data/lib/rble/cli/doctor.rb +309 -0
- data/lib/rble/cli/formatters/json.rb +122 -0
- data/lib/rble/cli/formatters/text.rb +157 -0
- data/lib/rble/cli/hex_dump.rb +48 -0
- data/lib/rble/cli/monitor.rb +129 -0
- data/lib/rble/cli/pair.rb +103 -0
- data/lib/rble/cli/paired.rb +22 -0
- data/lib/rble/cli/read.rb +55 -0
- data/lib/rble/cli/scan.rb +88 -0
- data/lib/rble/cli/show.rb +109 -0
- data/lib/rble/cli/status.rb +25 -0
- data/lib/rble/cli/unpair.rb +39 -0
- data/lib/rble/cli/value_parser.rb +211 -0
- data/lib/rble/cli/write.rb +196 -0
- data/lib/rble/cli.rb +152 -0
- data/lib/rble/company_ids.rb +90 -0
- data/lib/rble/connection.rb +539 -0
- data/lib/rble/device.rb +54 -0
- data/lib/rble/errors.rb +317 -0
- data/lib/rble/gatt/uuid_database.rb +395 -0
- data/lib/rble/scanner.rb +219 -0
- data/lib/rble/service.rb +41 -0
- data/lib/rble/tasks/check.rake +154 -0
- data/lib/rble/tasks/integration.rake +242 -0
- data/lib/rble/tasks.rb +8 -0
- data/lib/rble/version.rb +5 -0
- data/lib/rble.rb +62 -0
- 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
|