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,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