foodforthought-cli 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl

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 (131) hide show
  1. ate/__init__.py +6 -0
  2. ate/__main__.py +16 -0
  3. ate/auth/__init__.py +1 -0
  4. ate/auth/device_flow.py +141 -0
  5. ate/auth/token_store.py +96 -0
  6. ate/behaviors/__init__.py +100 -0
  7. ate/behaviors/approach.py +399 -0
  8. ate/behaviors/common.py +686 -0
  9. ate/behaviors/tree.py +454 -0
  10. ate/cli.py +855 -3995
  11. ate/client.py +90 -0
  12. ate/commands/__init__.py +168 -0
  13. ate/commands/auth.py +389 -0
  14. ate/commands/bridge.py +448 -0
  15. ate/commands/data.py +185 -0
  16. ate/commands/deps.py +111 -0
  17. ate/commands/generate.py +384 -0
  18. ate/commands/memory.py +907 -0
  19. ate/commands/parts.py +166 -0
  20. ate/commands/primitive.py +399 -0
  21. ate/commands/protocol.py +288 -0
  22. ate/commands/recording.py +524 -0
  23. ate/commands/repo.py +154 -0
  24. ate/commands/simulation.py +291 -0
  25. ate/commands/skill.py +303 -0
  26. ate/commands/skills.py +487 -0
  27. ate/commands/team.py +147 -0
  28. ate/commands/workflow.py +271 -0
  29. ate/detection/__init__.py +38 -0
  30. ate/detection/base.py +142 -0
  31. ate/detection/color_detector.py +399 -0
  32. ate/detection/trash_detector.py +322 -0
  33. ate/drivers/__init__.py +39 -0
  34. ate/drivers/ble_transport.py +405 -0
  35. ate/drivers/mechdog.py +942 -0
  36. ate/drivers/wifi_camera.py +477 -0
  37. ate/interfaces/__init__.py +187 -0
  38. ate/interfaces/base.py +273 -0
  39. ate/interfaces/body.py +267 -0
  40. ate/interfaces/detection.py +282 -0
  41. ate/interfaces/locomotion.py +422 -0
  42. ate/interfaces/manipulation.py +408 -0
  43. ate/interfaces/navigation.py +389 -0
  44. ate/interfaces/perception.py +362 -0
  45. ate/interfaces/sensors.py +247 -0
  46. ate/interfaces/types.py +371 -0
  47. ate/llm_proxy.py +239 -0
  48. ate/mcp_server.py +387 -0
  49. ate/memory/__init__.py +35 -0
  50. ate/memory/cloud.py +244 -0
  51. ate/memory/context.py +269 -0
  52. ate/memory/embeddings.py +184 -0
  53. ate/memory/export.py +26 -0
  54. ate/memory/merge.py +146 -0
  55. ate/memory/migrate/__init__.py +34 -0
  56. ate/memory/migrate/base.py +89 -0
  57. ate/memory/migrate/pipeline.py +189 -0
  58. ate/memory/migrate/sources/__init__.py +13 -0
  59. ate/memory/migrate/sources/chroma.py +170 -0
  60. ate/memory/migrate/sources/pinecone.py +120 -0
  61. ate/memory/migrate/sources/qdrant.py +110 -0
  62. ate/memory/migrate/sources/weaviate.py +160 -0
  63. ate/memory/reranker.py +353 -0
  64. ate/memory/search.py +26 -0
  65. ate/memory/store.py +548 -0
  66. ate/recording/__init__.py +83 -0
  67. ate/recording/demonstration.py +378 -0
  68. ate/recording/session.py +415 -0
  69. ate/recording/upload.py +304 -0
  70. ate/recording/visual.py +416 -0
  71. ate/recording/wrapper.py +95 -0
  72. ate/robot/__init__.py +221 -0
  73. ate/robot/agentic_servo.py +856 -0
  74. ate/robot/behaviors.py +493 -0
  75. ate/robot/ble_capture.py +1000 -0
  76. ate/robot/ble_enumerate.py +506 -0
  77. ate/robot/calibration.py +668 -0
  78. ate/robot/calibration_state.py +388 -0
  79. ate/robot/commands.py +3735 -0
  80. ate/robot/direction_calibration.py +554 -0
  81. ate/robot/discovery.py +441 -0
  82. ate/robot/introspection.py +330 -0
  83. ate/robot/llm_system_id.py +654 -0
  84. ate/robot/locomotion_calibration.py +508 -0
  85. ate/robot/manager.py +270 -0
  86. ate/robot/marker_generator.py +611 -0
  87. ate/robot/perception.py +502 -0
  88. ate/robot/primitives.py +614 -0
  89. ate/robot/profiles.py +281 -0
  90. ate/robot/registry.py +322 -0
  91. ate/robot/servo_mapper.py +1153 -0
  92. ate/robot/skill_upload.py +675 -0
  93. ate/robot/target_calibration.py +500 -0
  94. ate/robot/teach.py +515 -0
  95. ate/robot/types.py +242 -0
  96. ate/robot/visual_labeler.py +1048 -0
  97. ate/robot/visual_servo_loop.py +494 -0
  98. ate/robot/visual_servoing.py +570 -0
  99. ate/robot/visual_system_id.py +906 -0
  100. ate/transports/__init__.py +121 -0
  101. ate/transports/base.py +394 -0
  102. ate/transports/ble.py +405 -0
  103. ate/transports/hybrid.py +444 -0
  104. ate/transports/serial.py +345 -0
  105. ate/urdf/__init__.py +30 -0
  106. ate/urdf/capture.py +582 -0
  107. ate/urdf/cloud.py +491 -0
  108. ate/urdf/collision.py +271 -0
  109. ate/urdf/commands.py +708 -0
  110. ate/urdf/depth.py +360 -0
  111. ate/urdf/inertial.py +312 -0
  112. ate/urdf/kinematics.py +330 -0
  113. ate/urdf/lifting.py +415 -0
  114. ate/urdf/meshing.py +300 -0
  115. ate/urdf/models/__init__.py +110 -0
  116. ate/urdf/models/depth_anything.py +253 -0
  117. ate/urdf/models/sam2.py +324 -0
  118. ate/urdf/motion_analysis.py +396 -0
  119. ate/urdf/pipeline.py +468 -0
  120. ate/urdf/scale.py +256 -0
  121. ate/urdf/scan_session.py +411 -0
  122. ate/urdf/segmentation.py +299 -0
  123. ate/urdf/synthesis.py +319 -0
  124. ate/urdf/topology.py +336 -0
  125. ate/urdf/validation.py +371 -0
  126. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
  127. foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
  128. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
  129. foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
  130. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  131. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Comprehensive BLE Enumeration for Robots.
4
+
5
+ This module provides systematic BLE device enumeration that can discover:
6
+ 1. All GATT services and characteristics
7
+ 2. Which characteristics accept writes/reads/notifications
8
+ 3. What command protocols the device responds to
9
+ 4. Visual confirmation of command execution (if camera available)
10
+
11
+ Designed to work with any BLE-enabled robot, not just MechDog.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import struct
17
+ import time
18
+ from dataclasses import dataclass, field, asdict
19
+ from datetime import datetime
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import Optional, Callable, Any
23
+
24
+ from bleak import BleakClient, BleakScanner
25
+ from bleak.backends.characteristic import BleakGATTCharacteristic
26
+
27
+
28
+ class CommandResult(Enum):
29
+ """Result of sending a command."""
30
+ NO_RESPONSE = "no_response"
31
+ RESPONSE = "response"
32
+ ERROR = "error"
33
+ EXECUTED = "executed" # No response but visual/sensor confirmation
34
+
35
+
36
+ @dataclass
37
+ class CharacteristicInfo:
38
+ """Information about a BLE characteristic."""
39
+ uuid: str
40
+ handle: int
41
+ properties: list[str]
42
+ descriptors: list[str] = field(default_factory=list)
43
+ accepts_write: bool = False
44
+ accepts_read: bool = False
45
+ supports_notify: bool = False
46
+ read_value: Optional[bytes] = None
47
+ write_test_result: Optional[str] = None
48
+
49
+
50
+ @dataclass
51
+ class ServiceInfo:
52
+ """Information about a BLE service."""
53
+ uuid: str
54
+ handle: int
55
+ characteristics: list[CharacteristicInfo] = field(default_factory=list)
56
+
57
+
58
+ @dataclass
59
+ class CommandProbe:
60
+ """A command to probe and its result."""
61
+ command: bytes
62
+ description: str
63
+ response: Optional[bytes] = None
64
+ response_text: Optional[str] = None
65
+ result: CommandResult = CommandResult.NO_RESPONSE
66
+ latency_ms: float = 0.0
67
+
68
+
69
+ @dataclass
70
+ class ProtocolProbe:
71
+ """Results of probing a specific protocol."""
72
+ name: str
73
+ description: str
74
+ commands: list[CommandProbe] = field(default_factory=list)
75
+ working: bool = False
76
+
77
+
78
+ @dataclass
79
+ class BLEEnumerationReport:
80
+ """Complete enumeration report for a BLE device."""
81
+ device_name: str
82
+ device_address: str
83
+ timestamp: str
84
+ services: list[ServiceInfo] = field(default_factory=list)
85
+ protocols: list[ProtocolProbe] = field(default_factory=list)
86
+ primary_write_char: Optional[str] = None
87
+ primary_notify_char: Optional[str] = None
88
+ working_commands: list[str] = field(default_factory=list)
89
+ notes: list[str] = field(default_factory=list)
90
+
91
+ def to_dict(self) -> dict:
92
+ """Convert to dictionary for JSON serialization."""
93
+ data = asdict(self)
94
+
95
+ # Convert enum values, bytes, and bytearray to strings
96
+ def convert_types(obj):
97
+ if isinstance(obj, Enum):
98
+ return obj.value
99
+ if isinstance(obj, (bytes, bytearray)):
100
+ return obj.hex()
101
+ if isinstance(obj, dict):
102
+ return {k: convert_types(v) for k, v in obj.items()}
103
+ if isinstance(obj, list):
104
+ return [convert_types(i) for i in obj]
105
+ return obj
106
+
107
+ return convert_types(data)
108
+
109
+ def to_json(self, path: Path) -> None:
110
+ """Save report to JSON file."""
111
+ # Convert bytes to hex strings for JSON serialization
112
+ data = self.to_dict()
113
+
114
+ def convert_bytes(obj):
115
+ if isinstance(obj, bytes):
116
+ return obj.hex()
117
+ if isinstance(obj, dict):
118
+ return {k: convert_bytes(v) for k, v in obj.items()}
119
+ if isinstance(obj, list):
120
+ return [convert_bytes(i) for i in obj]
121
+ return obj
122
+
123
+ data = convert_bytes(data)
124
+ path.write_text(json.dumps(data, indent=2))
125
+
126
+
127
+ class BLEEnumerator:
128
+ """Systematic BLE device enumeration."""
129
+
130
+ # Common robot BLE protocols to probe
131
+ PROTOCOLS = {
132
+ "cmd_pipe": {
133
+ "description": "CMD|X|$ pipe-delimited protocol (HiWonder/Hiwonder robots)",
134
+ "commands": [
135
+ (b"CMD|6|$", "Battery query"),
136
+ (b"CMD|0|0|$", "Mode 0 (stop)"),
137
+ (b"CMD|1|0|$", "Pose mode"),
138
+ (b"CMD|2|1|$", "Action 1"),
139
+ (b"CMD|3|1|$", "Gait mode 1"),
140
+ (b"CMD|4|0|0|$", "Direction stop"),
141
+ (b"CMD|5|0|0|0|$", "Attitude neutral"),
142
+ (b"CMD|7|6|$", "Arm command 6"),
143
+ (b"CMD|7|7|$", "Arm command 7"),
144
+ ]
145
+ },
146
+ "micropython_repl": {
147
+ "description": "MicroPython REPL protocol",
148
+ "commands": [
149
+ (b"\x03", "Ctrl+C interrupt"),
150
+ (b"\x02", "Ctrl+B friendly REPL"),
151
+ (b"\r\n", "Empty line (prompt)"),
152
+ (b"print('test')\r\n", "Python print"),
153
+ (b"2+2\r\n", "Python eval"),
154
+ ]
155
+ },
156
+ "hiwonder_servo": {
157
+ "description": "HiWonder servo binary protocol (0x55 0x55 header)",
158
+ "commands": [
159
+ # Servo 1 read position
160
+ (bytes([0x55, 0x55, 0x01, 0x03, 0x1C, 0xE4]), "Servo 1 read pos"),
161
+ # Servo 254 (broadcast) read
162
+ (bytes([0x55, 0x55, 0xFE, 0x03, 0x1C, 0xE7]), "Broadcast read"),
163
+ ]
164
+ },
165
+ "json": {
166
+ "description": "JSON command protocol",
167
+ "commands": [
168
+ (b'{"cmd":"status"}', "JSON status"),
169
+ (b'{"cmd":"battery"}', "JSON battery"),
170
+ (b'{"action":"stop"}', "JSON stop"),
171
+ ]
172
+ },
173
+ "at_commands": {
174
+ "description": "AT command protocol",
175
+ "commands": [
176
+ (b"AT\r\n", "AT ping"),
177
+ (b"AT+VERSION\r\n", "AT version"),
178
+ (b"AT+NAME\r\n", "AT name"),
179
+ ]
180
+ },
181
+ }
182
+
183
+ def __init__(
184
+ self,
185
+ device_address: str,
186
+ timeout: float = 15.0,
187
+ command_delay: float = 0.3,
188
+ verbose: bool = True,
189
+ visual_callback: Optional[Callable[[], float]] = None,
190
+ ):
191
+ """
192
+ Initialize BLE enumerator.
193
+
194
+ Args:
195
+ device_address: BLE MAC address or UUID
196
+ timeout: Connection timeout in seconds
197
+ command_delay: Delay between commands in seconds
198
+ verbose: Print progress information
199
+ visual_callback: Optional callback that returns visual change percentage
200
+ """
201
+ self.device_address = device_address
202
+ self.timeout = timeout
203
+ self.command_delay = command_delay
204
+ self.verbose = verbose
205
+ self.visual_callback = visual_callback
206
+ self.report = None
207
+
208
+ def log(self, msg: str) -> None:
209
+ """Print if verbose mode enabled."""
210
+ if self.verbose:
211
+ print(msg)
212
+
213
+ async def scan_for_device(self) -> Optional[Any]:
214
+ """Scan for the target device."""
215
+ self.log(f"Scanning for device: {self.device_address}")
216
+ devices = await BleakScanner.discover(timeout=5.0)
217
+
218
+ for d in devices:
219
+ if d.address == self.device_address or d.name == self.device_address:
220
+ self.log(f" Found: {d.name} ({d.address})")
221
+ return d
222
+
223
+ # Try case-insensitive name match
224
+ for d in devices:
225
+ if d.name and self.device_address.lower() in d.name.lower():
226
+ self.log(f" Found by name: {d.name} ({d.address})")
227
+ return d
228
+
229
+ self.log(" Device not found")
230
+ return None
231
+
232
+ async def enumerate_services(self, client: BleakClient) -> list[ServiceInfo]:
233
+ """Enumerate all GATT services and characteristics."""
234
+ services = []
235
+
236
+ for service in client.services:
237
+ svc_info = ServiceInfo(
238
+ uuid=str(service.uuid),
239
+ handle=service.handle,
240
+ )
241
+
242
+ for char in service.characteristics:
243
+ char_info = CharacteristicInfo(
244
+ uuid=str(char.uuid),
245
+ handle=char.handle,
246
+ properties=char.properties,
247
+ descriptors=[str(d.uuid) for d in char.descriptors],
248
+ accepts_write="write" in char.properties or "write-without-response" in char.properties,
249
+ accepts_read="read" in char.properties,
250
+ supports_notify="notify" in char.properties or "indicate" in char.properties,
251
+ )
252
+
253
+ # Try reading if readable
254
+ if char_info.accepts_read:
255
+ try:
256
+ value = await client.read_gatt_char(char.uuid)
257
+ char_info.read_value = value
258
+ except Exception:
259
+ pass
260
+
261
+ svc_info.characteristics.append(char_info)
262
+
263
+ services.append(svc_info)
264
+
265
+ return services
266
+
267
+ async def probe_protocol(
268
+ self,
269
+ client: BleakClient,
270
+ write_char: str,
271
+ notify_char: str,
272
+ protocol_name: str,
273
+ protocol_info: dict,
274
+ ) -> ProtocolProbe:
275
+ """Probe a specific protocol."""
276
+ probe = ProtocolProbe(
277
+ name=protocol_name,
278
+ description=protocol_info["description"],
279
+ )
280
+
281
+ responses = []
282
+
283
+ def handler(sender, data):
284
+ responses.append((time.time(), data))
285
+ if self.verbose:
286
+ try:
287
+ self.log(f" << {data.decode('ascii', errors='replace')}")
288
+ except:
289
+ self.log(f" << {data.hex()}")
290
+
291
+ # Start notifications
292
+ try:
293
+ await client.start_notify(notify_char, handler)
294
+ await asyncio.sleep(0.5) # Give time to establish
295
+ except Exception as e:
296
+ self.log(f" Failed to start notify: {e}")
297
+
298
+ await asyncio.sleep(0.2)
299
+
300
+ for cmd_bytes, cmd_desc in protocol_info["commands"]:
301
+ cmd_probe = CommandProbe(
302
+ command=cmd_bytes,
303
+ description=cmd_desc,
304
+ )
305
+
306
+ responses.clear()
307
+ start = time.time()
308
+
309
+ try:
310
+ await client.write_gatt_char(write_char, cmd_bytes)
311
+ await asyncio.sleep(self.command_delay)
312
+
313
+ if responses:
314
+ latency = (responses[0][0] - start) * 1000
315
+ resp_data = responses[0][1]
316
+
317
+ cmd_probe.response = resp_data
318
+ cmd_probe.latency_ms = latency
319
+ cmd_probe.result = CommandResult.RESPONSE
320
+
321
+ try:
322
+ cmd_probe.response_text = resp_data.decode('ascii', errors='replace')
323
+ except:
324
+ cmd_probe.response_text = resp_data.hex()
325
+
326
+ probe.working = True
327
+ else:
328
+ # Check for visual movement if callback provided
329
+ if self.visual_callback:
330
+ change = self.visual_callback()
331
+ if change > 3.0: # 3% threshold
332
+ cmd_probe.result = CommandResult.EXECUTED
333
+ probe.working = True
334
+ else:
335
+ cmd_probe.result = CommandResult.NO_RESPONSE
336
+
337
+ except Exception as e:
338
+ cmd_probe.result = CommandResult.ERROR
339
+ cmd_probe.response_text = str(e)
340
+
341
+ probe.commands.append(cmd_probe)
342
+
343
+ try:
344
+ await client.stop_notify(notify_char)
345
+ except Exception:
346
+ pass
347
+
348
+ return probe
349
+
350
+ async def enumerate(self) -> BLEEnumerationReport:
351
+ """Run full BLE enumeration."""
352
+ device = await self.scan_for_device()
353
+ if not device:
354
+ raise RuntimeError(f"Device {self.device_address} not found")
355
+
356
+ self.report = BLEEnumerationReport(
357
+ device_name=device.name or "Unknown",
358
+ device_address=device.address,
359
+ timestamp=datetime.now().isoformat(),
360
+ )
361
+
362
+ self.log(f"\nConnecting to {device.name}...")
363
+
364
+ async with BleakClient(device, timeout=self.timeout) as client:
365
+ # Phase 1: Enumerate services
366
+ self.log("\n[1] Enumerating GATT services...")
367
+ self.report.services = await self.enumerate_services(client)
368
+
369
+ for svc in self.report.services:
370
+ self.log(f" Service: {svc.uuid}")
371
+ for char in svc.characteristics:
372
+ props = ", ".join(char.properties)
373
+ self.log(f" Char: {char.uuid} [{props}]")
374
+ if char.read_value:
375
+ try:
376
+ self.log(f" Value: {char.read_value.decode('ascii', errors='replace')}")
377
+ except:
378
+ self.log(f" Value: {char.read_value.hex()}")
379
+
380
+ # Find write and notify characteristics
381
+ write_chars = []
382
+ notify_chars = []
383
+
384
+ for svc in self.report.services:
385
+ for char in svc.characteristics:
386
+ if char.accepts_write:
387
+ write_chars.append(char.uuid)
388
+ if char.supports_notify:
389
+ notify_chars.append(char.uuid)
390
+
391
+ if not write_chars:
392
+ self.report.notes.append("No writable characteristics found")
393
+ return self.report
394
+
395
+ # Use first write/notify pair (common pattern)
396
+ write_char = write_chars[0]
397
+ notify_char = notify_chars[0] if notify_chars else write_chars[0]
398
+
399
+ self.report.primary_write_char = write_char
400
+ self.report.primary_notify_char = notify_char
401
+
402
+ self.log(f"\n Primary write: {write_char}")
403
+ self.log(f" Primary notify: {notify_char}")
404
+
405
+ # Phase 2: Probe protocols
406
+ self.log("\n[2] Probing command protocols...")
407
+
408
+ for proto_name, proto_info in self.PROTOCOLS.items():
409
+ self.log(f"\n Testing {proto_name}...")
410
+ probe = await self.probe_protocol(
411
+ client, write_char, notify_char, proto_name, proto_info
412
+ )
413
+ self.report.protocols.append(probe)
414
+
415
+ if probe.working:
416
+ self.log(f" ✓ {proto_name} WORKING")
417
+ for cmd in probe.commands:
418
+ if cmd.result in (CommandResult.RESPONSE, CommandResult.EXECUTED):
419
+ self.log(f" {cmd.description}: {cmd.response_text or 'executed'}")
420
+ self.report.working_commands.append(cmd.description)
421
+ else:
422
+ self.log(f" ✗ {proto_name} not responding")
423
+
424
+ # Phase 3: Deep scan of working protocols
425
+ if any(p.working for p in self.report.protocols):
426
+ self.log("\n[3] Deep scanning working protocols...")
427
+
428
+ # If CMD protocol works, scan more functions
429
+ cmd_proto = next((p for p in self.report.protocols if p.name == "cmd_pipe" and p.working), None)
430
+ if cmd_proto:
431
+ self.log(" Scanning CMD|X|$ functions 0-20...")
432
+
433
+ responses = []
434
+ def handler(s, d):
435
+ responses.append(d)
436
+
437
+ await client.start_notify(notify_char, handler)
438
+
439
+ for func in range(21):
440
+ responses.clear()
441
+ cmd = f"CMD|{func}|$".encode()
442
+ await client.write_gatt_char(write_char, cmd)
443
+ await asyncio.sleep(0.2)
444
+
445
+ if responses:
446
+ resp = responses[0].decode('ascii', errors='replace')
447
+ self.log(f" CMD|{func}|$ -> {resp}")
448
+ self.report.working_commands.append(f"CMD|{func}|$")
449
+
450
+ await client.stop_notify(notify_char)
451
+
452
+ # Summary
453
+ self.log("\n" + "=" * 60)
454
+ self.log("ENUMERATION COMPLETE")
455
+ self.log("=" * 60)
456
+ self.log(f"\nDevice: {self.report.device_name}")
457
+ self.log(f"Address: {self.report.device_address}")
458
+ self.log(f"Services: {len(self.report.services)}")
459
+ self.log(f"Working protocols: {[p.name for p in self.report.protocols if p.working]}")
460
+ self.log(f"Working commands: {len(self.report.working_commands)}")
461
+
462
+ return self.report
463
+
464
+
465
+ async def enumerate_ble_device(
466
+ address: str,
467
+ output_path: Optional[Path] = None,
468
+ verbose: bool = True,
469
+ ) -> BLEEnumerationReport:
470
+ """
471
+ Convenience function to enumerate a BLE device.
472
+
473
+ Args:
474
+ address: BLE device address or name
475
+ output_path: Optional path to save JSON report
476
+ verbose: Print progress information
477
+
478
+ Returns:
479
+ BLEEnumerationReport with all discovered information
480
+ """
481
+ enumerator = BLEEnumerator(address, verbose=verbose)
482
+ report = await enumerator.enumerate()
483
+
484
+ if output_path:
485
+ report.to_json(output_path)
486
+ if verbose:
487
+ print(f"\nReport saved to: {output_path}")
488
+
489
+ return report
490
+
491
+
492
+ # CLI integration
493
+ if __name__ == "__main__":
494
+ import sys
495
+
496
+ if len(sys.argv) < 2:
497
+ print("Usage: python ble_enumerate.py <device_address> [output.json]")
498
+ print("\nExample:")
499
+ print(" python ble_enumerate.py FAF198B6-D9A4-CC27-7BBA-3159F63A61A9")
500
+ print(" python ble_enumerate.py mechdog_00 enumeration.json")
501
+ sys.exit(1)
502
+
503
+ address = sys.argv[1]
504
+ output = Path(sys.argv[2]) if len(sys.argv) > 2 else None
505
+
506
+ asyncio.run(enumerate_ble_device(address, output))