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,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'etc'
|
|
5
|
+
begin
|
|
6
|
+
require_relative '../bluez'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# BlueZ backend not available (non-Linux platform)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module RBLE
|
|
12
|
+
module CLI
|
|
13
|
+
class Doctor
|
|
14
|
+
def initialize(options)
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execute
|
|
19
|
+
results = run_checks
|
|
20
|
+
|
|
21
|
+
if @options["json"]
|
|
22
|
+
print_json(results)
|
|
23
|
+
else
|
|
24
|
+
print_text(results)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
has_errors = results.any? { |r| r[:severity] == :error }
|
|
28
|
+
exit 1 if has_errors
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def linux?
|
|
34
|
+
RUBY_PLATFORM.include?('linux')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_checks
|
|
38
|
+
checks = %i[check_adapter_present check_adapter_powered]
|
|
39
|
+
|
|
40
|
+
if linux?
|
|
41
|
+
checks = %i[
|
|
42
|
+
check_ruby_dbus
|
|
43
|
+
check_kernel_module
|
|
44
|
+
check_bluetoothd_running
|
|
45
|
+
check_dbus_permissions
|
|
46
|
+
check_adapter_present
|
|
47
|
+
check_adapter_powered
|
|
48
|
+
check_rfkill
|
|
49
|
+
check_bluetooth_group
|
|
50
|
+
check_bluetoothd_version
|
|
51
|
+
]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
checks.filter_map do |check|
|
|
55
|
+
begin
|
|
56
|
+
send(check)
|
|
57
|
+
rescue => e
|
|
58
|
+
{ severity: :warning, title: check.to_s.sub("check_", "").tr("_", " "),
|
|
59
|
+
message: "Check failed: #{e.message}" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_text(results)
|
|
65
|
+
# Phase 1: Compact checklist
|
|
66
|
+
results.each do |result|
|
|
67
|
+
case result[:severity]
|
|
68
|
+
when :info
|
|
69
|
+
extra = compact_extra(result)
|
|
70
|
+
puts " OK #{result[:title]}#{extra}"
|
|
71
|
+
when :warning
|
|
72
|
+
puts "WARN #{result[:title]}"
|
|
73
|
+
when :error
|
|
74
|
+
puts "FAIL #{result[:title]}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Phase 2: Summary line
|
|
79
|
+
issues = results.select { |r| r[:severity] == :error || r[:severity] == :warning }
|
|
80
|
+
puts
|
|
81
|
+
if issues.empty?
|
|
82
|
+
puts "All checks passed"
|
|
83
|
+
else
|
|
84
|
+
puts "#{issues.size} issue#{'s' unless issues.size == 1} found:"
|
|
85
|
+
|
|
86
|
+
# Phase 3: Detailed issue section
|
|
87
|
+
puts
|
|
88
|
+
issues.each do |result|
|
|
89
|
+
label = result[:severity] == :error ? "ERROR" : "WARNING"
|
|
90
|
+
puts " #{label}: #{result[:title]}"
|
|
91
|
+
puts " #{result[:message]}"
|
|
92
|
+
puts " Fix: #{result[:fix]}" if result[:fix]
|
|
93
|
+
puts
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def compact_extra(result)
|
|
99
|
+
case result[:title]
|
|
100
|
+
when "Bluetooth adapter"
|
|
101
|
+
if result[:message] =~ /Adapter (\S+) found \(([^)]+)\)/
|
|
102
|
+
" (#{$1} #{$2})"
|
|
103
|
+
else
|
|
104
|
+
""
|
|
105
|
+
end
|
|
106
|
+
when "BlueZ version"
|
|
107
|
+
if result[:message] =~ /BlueZ (.+)/
|
|
108
|
+
" (#{$1})"
|
|
109
|
+
else
|
|
110
|
+
""
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
""
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def print_json(results)
|
|
118
|
+
errors = results.count { |r| r[:severity] == :error }
|
|
119
|
+
warnings = results.count { |r| r[:severity] == :warning }
|
|
120
|
+
info = results.count { |r| r[:severity] == :info }
|
|
121
|
+
|
|
122
|
+
output = {
|
|
123
|
+
summary: { errors: errors, warnings: warnings, info: info },
|
|
124
|
+
checks: results.map { |r|
|
|
125
|
+
h = { severity: r[:severity].to_s, title: r[:title], message: r[:message] }
|
|
126
|
+
h[:fix] = r[:fix] if r[:fix]
|
|
127
|
+
h
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
puts JSON.generate(output)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check 0: ruby-dbus gem available (Linux only)
|
|
135
|
+
def check_ruby_dbus
|
|
136
|
+
require 'dbus'
|
|
137
|
+
{ severity: :info, title: "ruby-dbus gem", message: "ruby-dbus gem is available" }
|
|
138
|
+
rescue LoadError
|
|
139
|
+
{ severity: :error, title: "ruby-dbus gem",
|
|
140
|
+
message: "ruby-dbus gem is not installed. The BlueZ backend requires it.",
|
|
141
|
+
fix: "gem install ruby-dbus" }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check 1: Kernel module loaded
|
|
145
|
+
def check_kernel_module
|
|
146
|
+
if File.exist?("/sys/module/bluetooth")
|
|
147
|
+
{ severity: :info, title: "Kernel module", message: "Bluetooth kernel module is loaded" }
|
|
148
|
+
else
|
|
149
|
+
{ severity: :error, title: "Kernel module",
|
|
150
|
+
message: "Bluetooth kernel module is not loaded",
|
|
151
|
+
fix: "sudo modprobe bluetooth" }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Check 2: bluetoothd running
|
|
156
|
+
def check_bluetoothd_running
|
|
157
|
+
running = if command_exists?("systemctl")
|
|
158
|
+
system("systemctl", "is-active", "--quiet", "bluetooth")
|
|
159
|
+
elsif command_exists?("pgrep")
|
|
160
|
+
system("pgrep", "-x", "bluetoothd", out: File::NULL, err: File::NULL)
|
|
161
|
+
else
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if running
|
|
166
|
+
{ severity: :info, title: "Bluetooth daemon", message: "bluetoothd is running" }
|
|
167
|
+
else
|
|
168
|
+
{ severity: :error, title: "Bluetooth daemon",
|
|
169
|
+
message: "bluetoothd service is not active",
|
|
170
|
+
fix: "sudo systemctl start bluetooth" }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Check 3: D-Bus permissions
|
|
175
|
+
def check_dbus_permissions
|
|
176
|
+
conn = RBLE::BlueZ::DBusConnection.new
|
|
177
|
+
conn.connect
|
|
178
|
+
conn.disconnect
|
|
179
|
+
{ severity: :info, title: "D-Bus access", message: "Can connect to BlueZ D-Bus service" }
|
|
180
|
+
rescue => e
|
|
181
|
+
{ severity: :error, title: "D-Bus access",
|
|
182
|
+
message: "Cannot connect to BlueZ D-Bus service: #{e.message}",
|
|
183
|
+
fix: "Ensure bluetoothd is running: sudo systemctl start bluetooth" }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Check 4: Adapter present
|
|
187
|
+
def check_adapter_present
|
|
188
|
+
backend = RBLE::Backend.for_platform
|
|
189
|
+
adapters = backend.adapters
|
|
190
|
+
if adapters.empty?
|
|
191
|
+
{ severity: :error, title: "Bluetooth adapter",
|
|
192
|
+
message: "No Bluetooth adapter found",
|
|
193
|
+
fix: "Check that adapter is plugged in or not blocked by rfkill" }
|
|
194
|
+
else
|
|
195
|
+
@first_adapter = adapters.first
|
|
196
|
+
{ severity: :info, title: "Bluetooth adapter",
|
|
197
|
+
message: "Adapter #{@first_adapter[:name]} found (#{@first_adapter[:address]})" }
|
|
198
|
+
end
|
|
199
|
+
rescue RBLE::AdapterNotFoundError
|
|
200
|
+
{ severity: :error, title: "Bluetooth adapter",
|
|
201
|
+
message: "No Bluetooth adapter found",
|
|
202
|
+
fix: "Check that adapter is plugged in or not blocked by rfkill" }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Check 5: Adapter powered
|
|
206
|
+
def check_adapter_powered
|
|
207
|
+
return nil unless @first_adapter
|
|
208
|
+
|
|
209
|
+
if @first_adapter[:powered]
|
|
210
|
+
{ severity: :info, title: "Adapter power", message: "Adapter is powered on" }
|
|
211
|
+
else
|
|
212
|
+
{ severity: :error, title: "Adapter power",
|
|
213
|
+
message: "Adapter #{@first_adapter[:name]} is not powered on",
|
|
214
|
+
fix: "rble adapter power on" }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Check 6: rfkill blocks
|
|
219
|
+
def check_rfkill
|
|
220
|
+
return nil unless command_exists?("rfkill")
|
|
221
|
+
|
|
222
|
+
output, success = run_command("rfkill --json 2>/dev/null")
|
|
223
|
+
unless success
|
|
224
|
+
output, success = run_command("rfkill list bluetooth 2>/dev/null")
|
|
225
|
+
return nil unless success
|
|
226
|
+
return parse_rfkill_text(output)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
data = JSON.parse(output)
|
|
230
|
+
bt_devices = data.dig("rfkilldevices")&.select { |d| d["type"] == "bluetooth" } || []
|
|
231
|
+
|
|
232
|
+
if bt_devices.empty?
|
|
233
|
+
{ severity: :info, title: "RF kill", message: "No Bluetooth devices in rfkill" }
|
|
234
|
+
else
|
|
235
|
+
hard_blocked = bt_devices.any? { |d| d["hard"] == "blocked" }
|
|
236
|
+
soft_blocked = bt_devices.any? { |d| d["soft"] == "blocked" }
|
|
237
|
+
|
|
238
|
+
if hard_blocked
|
|
239
|
+
{ severity: :error, title: "RF kill",
|
|
240
|
+
message: "Bluetooth is hard-blocked (hardware switch)",
|
|
241
|
+
fix: "Check for a physical Bluetooth/wireless switch on your device" }
|
|
242
|
+
elsif soft_blocked
|
|
243
|
+
{ severity: :error, title: "RF kill",
|
|
244
|
+
message: "Bluetooth is soft-blocked by rfkill",
|
|
245
|
+
fix: "sudo rfkill unblock bluetooth" }
|
|
246
|
+
else
|
|
247
|
+
{ severity: :info, title: "RF kill", message: "Bluetooth is not blocked" }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Check 7: User in bluetooth group
|
|
253
|
+
def check_bluetooth_group
|
|
254
|
+
group = Etc.getgrnam("bluetooth")
|
|
255
|
+
if Process.groups.include?(group.gid)
|
|
256
|
+
{ severity: :info, title: "Bluetooth group",
|
|
257
|
+
message: "User is in 'bluetooth' group" }
|
|
258
|
+
else
|
|
259
|
+
username = Etc.getlogin || ENV["USER"] || "unknown"
|
|
260
|
+
{ severity: :warning, title: "Bluetooth group",
|
|
261
|
+
message: "User '#{username}' is not a member of the 'bluetooth' group",
|
|
262
|
+
fix: "sudo usermod -aG bluetooth #{username}" }
|
|
263
|
+
end
|
|
264
|
+
rescue ArgumentError
|
|
265
|
+
{ severity: :info, title: "Bluetooth group",
|
|
266
|
+
message: "'bluetooth' group does not exist on this system" }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Check 8: bluetoothd version
|
|
270
|
+
def check_bluetoothd_version
|
|
271
|
+
return nil unless command_exists?("bluetoothd")
|
|
272
|
+
|
|
273
|
+
output, success = run_command("bluetoothd --version 2>/dev/null")
|
|
274
|
+
return nil unless success
|
|
275
|
+
|
|
276
|
+
version = output.strip
|
|
277
|
+
return nil if version.empty?
|
|
278
|
+
|
|
279
|
+
{ severity: :info, title: "BlueZ version", message: "BlueZ #{version}" }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def command_exists?(cmd)
|
|
283
|
+
system("which", cmd, out: File::NULL, err: File::NULL)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def run_command(cmd)
|
|
287
|
+
output = `#{cmd}`
|
|
288
|
+
[output, $?.success?]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def parse_rfkill_text(output)
|
|
292
|
+
hard_blocked = output.include?("Hard blocked: yes")
|
|
293
|
+
soft_blocked = output.include?("Soft blocked: yes")
|
|
294
|
+
|
|
295
|
+
if hard_blocked
|
|
296
|
+
{ severity: :error, title: "RF kill",
|
|
297
|
+
message: "Bluetooth is hard-blocked (hardware switch)",
|
|
298
|
+
fix: "Check for a physical Bluetooth/wireless switch on your device" }
|
|
299
|
+
elsif soft_blocked
|
|
300
|
+
{ severity: :error, title: "RF kill",
|
|
301
|
+
message: "Bluetooth is soft-blocked by rfkill",
|
|
302
|
+
fix: "sudo rfkill unblock bluetooth" }
|
|
303
|
+
else
|
|
304
|
+
{ severity: :info, title: "RF kill", message: "Bluetooth is not blocked" }
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'rble/company_ids'
|
|
6
|
+
|
|
7
|
+
module RBLE
|
|
8
|
+
module CLI
|
|
9
|
+
module Formatters
|
|
10
|
+
class Json
|
|
11
|
+
def device(device)
|
|
12
|
+
data = {
|
|
13
|
+
address: device.address,
|
|
14
|
+
name: device.name,
|
|
15
|
+
rssi: device.rssi,
|
|
16
|
+
address_type: device.address_type
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if device.manufacturer_data.any?
|
|
20
|
+
company_id = device.manufacturer_data.keys.first
|
|
21
|
+
data[:company_id] = company_id
|
|
22
|
+
data[:company] = RBLE::CompanyIdentifiers.resolve(company_id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts JSON.generate(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def status(info)
|
|
29
|
+
puts JSON.generate(info)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def adapter_list(adapters)
|
|
33
|
+
puts JSON.generate(adapters)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def adapter_confirm(message)
|
|
37
|
+
puts JSON.generate({ status: "ok", message: message })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def paired_list(devices)
|
|
41
|
+
puts JSON.generate(devices)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pair_result(success:, address:, message:)
|
|
45
|
+
puts JSON.generate({
|
|
46
|
+
status: success ? "ok" : "error",
|
|
47
|
+
address: address,
|
|
48
|
+
message: message
|
|
49
|
+
})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def read_value(address:, uuid:, name:, raw:, formatted:)
|
|
53
|
+
puts JSON.generate({
|
|
54
|
+
address: address,
|
|
55
|
+
characteristic: {
|
|
56
|
+
uuid: uuid,
|
|
57
|
+
name: name,
|
|
58
|
+
value: formatted,
|
|
59
|
+
raw_hex: raw.bytes.map { |b| format("%02x", b) }.join
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write_result(address:, uuid:, name:, success:, verified: nil, written_bytes: nil)
|
|
65
|
+
output = {
|
|
66
|
+
address: address,
|
|
67
|
+
characteristic: {
|
|
68
|
+
uuid: uuid,
|
|
69
|
+
name: name
|
|
70
|
+
},
|
|
71
|
+
status: success ? "ok" : "error"
|
|
72
|
+
}
|
|
73
|
+
output[:written] = written_bytes.bytes if written_bytes
|
|
74
|
+
output[:verified] = verified unless verified.nil?
|
|
75
|
+
puts JSON.generate(output)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def monitor_value(address:, uuid:, name:, raw:, formatted:, timestamp:)
|
|
79
|
+
puts JSON.generate({
|
|
80
|
+
timestamp: timestamp.iso8601(3),
|
|
81
|
+
address: address,
|
|
82
|
+
uuid: uuid,
|
|
83
|
+
name: name,
|
|
84
|
+
raw_hex: raw.bytes.map { |b| format("%02x", b) }.join,
|
|
85
|
+
value: formatted
|
|
86
|
+
})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def show_tree(tree_data)
|
|
90
|
+
output = {
|
|
91
|
+
address: tree_data[:address],
|
|
92
|
+
name: tree_data[:name],
|
|
93
|
+
services: tree_data[:services].map do |service|
|
|
94
|
+
{
|
|
95
|
+
uuid: service[:uuid],
|
|
96
|
+
name: service[:resolved_name],
|
|
97
|
+
primary: service[:primary],
|
|
98
|
+
characteristics: service[:characteristics].map do |char|
|
|
99
|
+
{
|
|
100
|
+
uuid: char[:uuid],
|
|
101
|
+
name: char[:resolved_name],
|
|
102
|
+
properties: char[:properties],
|
|
103
|
+
handle: char[:handle],
|
|
104
|
+
descriptors: (char[:descriptors] || []).map do |desc|
|
|
105
|
+
{
|
|
106
|
+
uuid: desc[:uuid],
|
|
107
|
+
name: desc[:resolved_name],
|
|
108
|
+
handle: desc[:handle]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
puts JSON.generate(output)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rble/company_ids'
|
|
4
|
+
|
|
5
|
+
module RBLE
|
|
6
|
+
module CLI
|
|
7
|
+
module Formatters
|
|
8
|
+
class Text
|
|
9
|
+
def device(device)
|
|
10
|
+
name = truncate(device.name || company_label(device) || "(unknown)", 25)
|
|
11
|
+
rssi_str = device.rssi ? format("%4d dBm", device.rssi) : " N/A"
|
|
12
|
+
puts format("%-17s %-25s %s %s", device.address, name, rssi_str, device.address_type)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def status(info)
|
|
16
|
+
puts "Adapter: #{info[:name]}"
|
|
17
|
+
puts "Address: #{info[:address]}"
|
|
18
|
+
puts "Alias: #{info[:alias]}" if info[:alias]
|
|
19
|
+
puts "Powered: #{info[:powered] ? 'yes' : 'no'}"
|
|
20
|
+
puts "Discoverable: #{info[:discoverable] ? 'yes' : 'no'}"
|
|
21
|
+
puts "Pairable: #{info[:pairable] ? 'yes' : 'no'}"
|
|
22
|
+
puts "Discovering: #{info[:discovering] ? 'yes' : 'no'}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def adapter_list(adapters)
|
|
26
|
+
if adapters.empty?
|
|
27
|
+
puts "No Bluetooth adapters found"
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
adapters.each do |a|
|
|
32
|
+
powered = a[:powered] ? "up" : "down"
|
|
33
|
+
puts format("%-8s %-17s %s", a[:name], a[:address], powered)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def adapter_confirm(message)
|
|
38
|
+
puts message
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def paired_list(devices)
|
|
42
|
+
if devices.empty?
|
|
43
|
+
puts "No bonded devices"
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
devices.each do |device|
|
|
48
|
+
name = truncate(device[:name] || "Unknown", 25)
|
|
49
|
+
connected_str = device[:connected] ? "connected" : "not connected"
|
|
50
|
+
type_str = device[:address_type] == "random" ? "BLE (random)" : "BLE (public)"
|
|
51
|
+
puts format("%-17s %-25s %-13s %s", device[:address], name, connected_str, type_str)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts "\n#{devices.size} bonded device#{'s' unless devices.size == 1}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pair_result(success:, address:, message:)
|
|
58
|
+
# Text mode: status already printed on stderr, no stdout output needed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_value(address:, uuid:, name:, raw:, formatted:)
|
|
62
|
+
short = RBLE::GATT::UUIDDatabase.extract_short_uuid(uuid)
|
|
63
|
+
label = name ? "#{name} (#{short})" : short
|
|
64
|
+
puts "#{label}: #{formatted}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write_result(address:, uuid:, name:, success:, verified: nil, written_bytes: nil)
|
|
68
|
+
# Text mode: status already printed on stderr, no stdout output needed
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def monitor_value(address:, uuid:, name:, raw:, formatted:, timestamp:)
|
|
72
|
+
ts = timestamp.strftime("%H:%M:%S.%L")
|
|
73
|
+
label = name || uuid
|
|
74
|
+
puts "[#{ts}] #{label}: #{formatted}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_tree(tree_data)
|
|
78
|
+
header = if tree_data[:name]
|
|
79
|
+
"#{tree_data[:name]} (#{tree_data[:address]})"
|
|
80
|
+
else
|
|
81
|
+
tree_data[:address]
|
|
82
|
+
end
|
|
83
|
+
service_count = tree_data[:services].size
|
|
84
|
+
char_count = tree_data[:total_characteristics]
|
|
85
|
+
puts "#{header} - #{service_count} service#{'s' unless service_count == 1}, " \
|
|
86
|
+
"#{char_count} characteristic#{'s' unless char_count == 1}"
|
|
87
|
+
puts
|
|
88
|
+
|
|
89
|
+
tree_data[:services].each do |service|
|
|
90
|
+
puts format_uuid_line(service[:uuid], service[:resolved_name], type: :service)
|
|
91
|
+
|
|
92
|
+
chars = service[:characteristics]
|
|
93
|
+
chars.each_with_index do |char, ci|
|
|
94
|
+
last_char = ci == chars.size - 1
|
|
95
|
+
char_prefix = last_char ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 "
|
|
96
|
+
props = "(#{char[:properties].join(', ')})"
|
|
97
|
+
char_line = format_uuid_line(char[:uuid], char[:resolved_name], type: :characteristic)
|
|
98
|
+
puts "#{char_prefix}#{char_line} #{props}"
|
|
99
|
+
|
|
100
|
+
descs = char[:descriptors] || []
|
|
101
|
+
descs.each_with_index do |desc, di|
|
|
102
|
+
last_desc = di == descs.size - 1
|
|
103
|
+
vert = last_char ? " " : "\u2502 "
|
|
104
|
+
desc_prefix = last_desc ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 "
|
|
105
|
+
desc_line = format_uuid_line(desc[:uuid], desc[:resolved_name], type: :descriptor)
|
|
106
|
+
puts "#{vert}#{desc_prefix}#{desc_line}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
puts
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def format_uuid_line(uuid, resolved_name, type:)
|
|
116
|
+
type_label = case type
|
|
117
|
+
when :service then "Service"
|
|
118
|
+
when :characteristic then "Characteristic"
|
|
119
|
+
when :descriptor then "Descriptor"
|
|
120
|
+
else "Unknown"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
is_standard = RBLE::GATT::UUIDDatabase.standard_uuid?(uuid)
|
|
124
|
+
short = RBLE::GATT::UUIDDatabase.extract_short_uuid(uuid)
|
|
125
|
+
|
|
126
|
+
if resolved_name
|
|
127
|
+
if is_standard
|
|
128
|
+
"#{resolved_name} [0x#{short.upcase}]"
|
|
129
|
+
else
|
|
130
|
+
"#{resolved_name} [#{uuid}]"
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
if is_standard
|
|
134
|
+
"Unknown #{type_label} [0x#{short.upcase}]"
|
|
135
|
+
else
|
|
136
|
+
"Unknown #{type_label} [#{uuid}]"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def company_label(device)
|
|
142
|
+
return nil if device.manufacturer_data.empty?
|
|
143
|
+
|
|
144
|
+
company_id = device.manufacturer_data.keys.first
|
|
145
|
+
name = RBLE::CompanyIdentifiers.resolve(company_id)
|
|
146
|
+
name ? "(#{name})" : nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def truncate(str, max)
|
|
150
|
+
return str if str.length <= max
|
|
151
|
+
|
|
152
|
+
str[0, max - 1] + "~"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RBLE
|
|
4
|
+
module CLI
|
|
5
|
+
# Hex + ASCII dump formatter for unknown BLE characteristic data.
|
|
6
|
+
#
|
|
7
|
+
# Produces output similar to `hexdump -C` with hex bytes on the left
|
|
8
|
+
# and printable ASCII on the right, wrapped in pipe delimiters.
|
|
9
|
+
#
|
|
10
|
+
# @example Short data (single line)
|
|
11
|
+
# HexDump.format("\x10\x41\x00\xFF".b)
|
|
12
|
+
# # => "10 41 00 FF |.A..|"
|
|
13
|
+
#
|
|
14
|
+
# @example Multi-line data
|
|
15
|
+
# HexDump.format(long_data)
|
|
16
|
+
# # => "41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 50 |ABCDEFGHIJKLMNOP|"
|
|
17
|
+
# # "51 52 53 54 |QRST|"
|
|
18
|
+
module HexDump
|
|
19
|
+
BYTES_PER_ROW = 16
|
|
20
|
+
|
|
21
|
+
# Format raw bytes as hex + ASCII dump.
|
|
22
|
+
#
|
|
23
|
+
# @param raw_bytes [String, nil] Binary string to format
|
|
24
|
+
# @return [String] Formatted hex+ASCII dump (empty string if no data)
|
|
25
|
+
def self.format(raw_bytes)
|
|
26
|
+
return "" if raw_bytes.nil? || raw_bytes.empty?
|
|
27
|
+
|
|
28
|
+
bytes = raw_bytes.bytes
|
|
29
|
+
rows = bytes.each_slice(BYTES_PER_ROW).map { |row| format_row(row) }
|
|
30
|
+
rows.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Format a single row of bytes.
|
|
34
|
+
#
|
|
35
|
+
# @param row [Array<Integer>] Up to 16 bytes
|
|
36
|
+
# @return [String] Formatted row with hex and ASCII sections
|
|
37
|
+
def self.format_row(row)
|
|
38
|
+
hex = row.map { |b| "%02X" % b }.join(' ')
|
|
39
|
+
# Pad hex section to consistent width: 16 * 3 - 1 = 47 chars
|
|
40
|
+
hex = hex.ljust(47)
|
|
41
|
+
ascii = row.map { |b| (0x20..0x7E).include?(b) ? b.chr : '.' }.join
|
|
42
|
+
"#{hex} |#{ascii}|"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :format_row
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|