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,1000 @@
1
+ """
2
+ BLE Protocol Capture and Analysis Tools.
3
+
4
+ Automates the workflow for reverse-engineering BLE robot protocols by:
5
+ 1. Capturing traffic from phone apps (iOS PacketLogger / Android HCI snoop)
6
+ 2. Analyzing captures to decode commands
7
+ 3. Generating Python code for protocol replay
8
+
9
+ This addresses a critical gap in open-source robotics: many robots have
10
+ undocumented BLE protocols with only proprietary phone apps for control.
11
+
12
+ Usage:
13
+ ate robot ble-capture --platform ios --output capture.pklg
14
+ ate robot ble-analyze capture.pklg
15
+ ate robot ble-inspect <address>
16
+ """
17
+
18
+ import os
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Dict, List, Optional, Tuple
28
+ from collections import defaultdict
29
+
30
+
31
+ @dataclass
32
+ class BLECommand:
33
+ """Represents a decoded BLE command."""
34
+ frame: int
35
+ opcode: str # 'write' or 'notify'
36
+ handle: int
37
+ raw_hex: str
38
+ decoded: Optional[str] = None
39
+
40
+ @property
41
+ def is_write(self) -> bool:
42
+ return 'write' in self.opcode.lower() or self.opcode == '0x12'
43
+
44
+ @property
45
+ def is_notify(self) -> bool:
46
+ return 'notif' in self.opcode.lower() or self.opcode == '0x1b'
47
+
48
+
49
+ @dataclass
50
+ class CaptureAnalysis:
51
+ """Results of analyzing a BLE capture."""
52
+ file_path: str
53
+ write_handle: Optional[int] = None
54
+ notify_handle: Optional[int] = None
55
+ commands: List[BLECommand] = field(default_factory=list)
56
+ unique_writes: Dict[str, int] = field(default_factory=dict) # cmd -> count
57
+ unique_notifies: Dict[str, int] = field(default_factory=dict)
58
+ protocol_detected: Optional[str] = None
59
+
60
+ def summary(self) -> str:
61
+ """Generate a human-readable summary."""
62
+ lines = [
63
+ "=" * 60,
64
+ "BLE CAPTURE ANALYSIS",
65
+ "=" * 60,
66
+ f"File: {self.file_path}",
67
+ f"Total packets: {len(self.commands)}",
68
+ "",
69
+ ]
70
+
71
+ if self.write_handle:
72
+ lines.append(f"WRITE Handle: 0x{self.write_handle:04x} ({self.write_handle})")
73
+ if self.notify_handle:
74
+ lines.append(f"NOTIFY Handle: 0x{self.notify_handle:04x} ({self.notify_handle})")
75
+
76
+ if self.protocol_detected:
77
+ lines.append(f"Protocol: {self.protocol_detected}")
78
+
79
+ lines.append("")
80
+ lines.append("-" * 60)
81
+ lines.append("WRITE COMMANDS (App → Robot):")
82
+ lines.append("-" * 60)
83
+
84
+ for cmd, count in sorted(self.unique_writes.items(), key=lambda x: -x[1]):
85
+ lines.append(f" {cmd:30s} ({count}x)")
86
+
87
+ if self.unique_notifies:
88
+ lines.append("")
89
+ lines.append("-" * 60)
90
+ lines.append("NOTIFICATIONS (Robot → App):")
91
+ lines.append("-" * 60)
92
+
93
+ for cmd, count in sorted(self.unique_notifies.items(), key=lambda x: -x[1]):
94
+ lines.append(f" {cmd:30s} ({count}x)")
95
+
96
+ lines.append("")
97
+ lines.append("=" * 60)
98
+
99
+ return "\n".join(lines)
100
+
101
+ def generate_python_code(self) -> str:
102
+ """Generate Python code to replay the captured protocol."""
103
+ code_lines = [
104
+ '"""',
105
+ f'Auto-generated BLE protocol from: {os.path.basename(self.file_path)}',
106
+ '',
107
+ 'Usage:',
108
+ ' python this_file.py',
109
+ '"""',
110
+ '',
111
+ 'import asyncio',
112
+ 'from bleak import BleakClient, BleakScanner',
113
+ '',
114
+ '# Device configuration',
115
+ 'DEVICE_NAME = "your_device_name" # Update this',
116
+ '',
117
+ '# Characteristic UUIDs (common ESP32/HM-10 serial)',
118
+ 'FFE1_WRITE = "0000ffe1-0000-1000-8000-00805f9b34fb"',
119
+ 'FFE2_NOTIFY = "0000ffe2-0000-1000-8000-00805f9b34fb"',
120
+ '',
121
+ '# Discovered commands',
122
+ 'COMMANDS = {',
123
+ ]
124
+
125
+ # Add discovered commands
126
+ for cmd in sorted(self.unique_writes.keys()):
127
+ # Try to infer command purpose
128
+ purpose = self._infer_purpose(cmd)
129
+ safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', purpose.lower())
130
+ code_lines.append(f' "{safe_name}": b"{cmd}",')
131
+
132
+ code_lines.extend([
133
+ '}',
134
+ '',
135
+ '',
136
+ 'def notification_handler(sender, data):',
137
+ ' """Handle responses from the device."""',
138
+ ' try:',
139
+ ' decoded = data.decode("ascii")',
140
+ ' print(f"Response: {decoded}")',
141
+ ' except:',
142
+ ' print(f"Response (hex): {data.hex()}")',
143
+ '',
144
+ '',
145
+ 'async def main():',
146
+ ' print("Scanning for device...")',
147
+ ' ',
148
+ ' device = None',
149
+ ' devices = await BleakScanner.discover(timeout=5.0)',
150
+ ' for d in devices:',
151
+ ' if d.name and DEVICE_NAME.lower() in d.name.lower():',
152
+ ' device = d',
153
+ ' break',
154
+ ' ',
155
+ ' if not device:',
156
+ ' print(f"Device {DEVICE_NAME} not found!")',
157
+ ' return',
158
+ ' ',
159
+ ' print(f"Found: {device.name}")',
160
+ ' ',
161
+ ' async with BleakClient(device, timeout=15.0) as client:',
162
+ ' print("Connected!")',
163
+ ' ',
164
+ ' # Subscribe to notifications',
165
+ ' await client.start_notify(FFE2_NOTIFY, notification_handler)',
166
+ ' await asyncio.sleep(0.3)',
167
+ ' ',
168
+ ' # Test each command',
169
+ ' for name, cmd in COMMANDS.items():',
170
+ ' print(f"\\nSending: {name}")',
171
+ ' await client.write_gatt_char(FFE1_WRITE, cmd)',
172
+ ' await asyncio.sleep(1.0)',
173
+ ' ',
174
+ ' await client.stop_notify(FFE2_NOTIFY)',
175
+ ' print("\\nDone!")',
176
+ '',
177
+ '',
178
+ 'if __name__ == "__main__":',
179
+ ' asyncio.run(main())',
180
+ ])
181
+
182
+ return "\n".join(code_lines)
183
+
184
+ def _infer_purpose(self, cmd: str) -> str:
185
+ """Try to infer the purpose of a command."""
186
+ # Common patterns for HiWonder CMD protocol
187
+ if cmd.startswith("CMD|"):
188
+ parts = cmd.rstrip("|$").split("|")
189
+ if len(parts) >= 2:
190
+ func = parts[1]
191
+ if func == "0":
192
+ return "init"
193
+ elif func == "1":
194
+ if len(parts) >= 3:
195
+ sub = parts[2]
196
+ if sub == "1":
197
+ return "pitch"
198
+ elif sub == "2":
199
+ return "roll"
200
+ elif sub == "3":
201
+ return "balance"
202
+ elif sub == "4":
203
+ return "height"
204
+ elif sub == "5":
205
+ return "reset_posture"
206
+ return "posture"
207
+ elif func == "2":
208
+ return f"action_group_{parts[3] if len(parts) > 3 else '1'}"
209
+ elif func == "3":
210
+ if len(parts) >= 3:
211
+ sub = parts[2]
212
+ mapping = {
213
+ "0": "stop",
214
+ "1": "turn_right_small",
215
+ "3": "forward",
216
+ "4": "turn_left_large",
217
+ "5": "turn_left_small",
218
+ "7": "backward",
219
+ }
220
+ return mapping.get(sub, f"locomotion_{sub}")
221
+ return "locomotion"
222
+ elif func == "6":
223
+ return "battery_query"
224
+
225
+ # Default: use sanitized command
226
+ return cmd[:20].replace("|", "_").replace("$", "")
227
+
228
+
229
+ def check_tshark() -> Optional[str]:
230
+ """Check if tshark is available and return its path."""
231
+ # Common paths
232
+ paths = [
233
+ "/opt/homebrew/bin/tshark", # Homebrew on Apple Silicon
234
+ "/usr/local/bin/tshark", # Homebrew on Intel Mac
235
+ "/usr/bin/tshark", # Linux
236
+ shutil.which("tshark"), # PATH lookup
237
+ ]
238
+
239
+ for path in paths:
240
+ if path and os.path.exists(path):
241
+ return path
242
+
243
+ return None
244
+
245
+
246
+ def check_adb() -> Optional[str]:
247
+ """Check if adb is available and return its path."""
248
+ paths = [
249
+ "/opt/homebrew/bin/adb",
250
+ "/usr/local/bin/adb",
251
+ shutil.which("adb"),
252
+ ]
253
+
254
+ for path in paths:
255
+ if path and os.path.exists(path):
256
+ return path
257
+
258
+ return None
259
+
260
+
261
+ def check_ios_device() -> Tuple[bool, str]:
262
+ """Check if an iOS device is connected via USB."""
263
+ try:
264
+ result = subprocess.run(
265
+ ["system_profiler", "SPUSBDataType"],
266
+ capture_output=True,
267
+ text=True,
268
+ timeout=10,
269
+ )
270
+ if "iPhone" in result.stdout or "iPad" in result.stdout:
271
+ return True, "iOS device detected via USB"
272
+ return False, "No iOS device found. Connect iPhone via USB cable."
273
+ except Exception as e:
274
+ return False, f"Could not check for iOS device: {e}"
275
+
276
+
277
+ def check_android_device() -> Tuple[bool, str]:
278
+ """Check if an Android device is connected via ADB."""
279
+ adb = check_adb()
280
+ if not adb:
281
+ return False, "adb not found. Install with: brew install android-platform-tools"
282
+
283
+ try:
284
+ result = subprocess.run(
285
+ [adb, "devices"],
286
+ capture_output=True,
287
+ text=True,
288
+ timeout=10,
289
+ )
290
+ lines = result.stdout.strip().split("\n")
291
+ # Skip header line, look for devices
292
+ devices = [l for l in lines[1:] if l.strip() and "device" in l]
293
+ if devices:
294
+ return True, f"Android device connected: {devices[0].split()[0]}"
295
+ return False, "No Android device found. Enable USB debugging and connect via USB."
296
+ except Exception as e:
297
+ return False, f"Could not check for Android device: {e}"
298
+
299
+
300
+ def capture_ios_interactive(output_path: str, duration: int = 60) -> bool:
301
+ """
302
+ Guide user through iOS BLE capture using PacketLogger.
303
+
304
+ Returns True if capture was successful.
305
+ """
306
+ print("=" * 60)
307
+ print("iOS BLE CAPTURE WORKFLOW")
308
+ print("=" * 60)
309
+ print()
310
+
311
+ # Check prerequisites
312
+ print("Checking prerequisites...")
313
+
314
+ # Check for iOS device
315
+ ios_ok, ios_msg = check_ios_device()
316
+ if ios_ok:
317
+ print(f" ✓ {ios_msg}")
318
+ else:
319
+ print(f" ✗ {ios_msg}")
320
+ return False
321
+
322
+ # Check for PacketLogger
323
+ packet_logger_paths = [
324
+ "/Applications/PacketLogger.app",
325
+ os.path.expanduser("~/Applications/PacketLogger.app"),
326
+ "/Applications/Additional Tools/Hardware/PacketLogger.app",
327
+ ]
328
+
329
+ packet_logger = None
330
+ for path in packet_logger_paths:
331
+ if os.path.exists(path):
332
+ packet_logger = path
333
+ break
334
+
335
+ if packet_logger:
336
+ print(f" ✓ PacketLogger found at {packet_logger}")
337
+ else:
338
+ print(" ✗ PacketLogger not found")
339
+ print()
340
+ print("To install PacketLogger:")
341
+ print(" 1. Go to: https://developer.apple.com/download/all/")
342
+ print(" 2. Search for 'Additional Tools for Xcode'")
343
+ print(" 3. Download and open the DMG")
344
+ print(" 4. Copy PacketLogger.app from Hardware folder to /Applications")
345
+ return False
346
+
347
+ # Check for tshark (for analysis)
348
+ tshark = check_tshark()
349
+ if tshark:
350
+ print(f" ✓ tshark found at {tshark}")
351
+ else:
352
+ print(" ⚠ tshark not found (needed for analysis)")
353
+ print(" Install with: brew install wireshark")
354
+
355
+ print()
356
+ print("-" * 60)
357
+ print("IMPORTANT: Bluetooth Logging Profile Required")
358
+ print("-" * 60)
359
+ print("""
360
+ If you haven't already, install the Bluetooth logging profile on your iPhone:
361
+
362
+ 1. On your iPhone, open Safari
363
+ 2. Go to: https://developer.apple.com/bug-reporting/profiles-and-logs/?name=bluetooth
364
+ 3. Sign in with your Apple ID (developer account required)
365
+ 4. Download and install the 'Bluetooth for iOS' profile
366
+ 5. Go to Settings > General > VPN & Device Management
367
+ 6. Tap the Bluetooth profile and install it
368
+ 7. RESTART YOUR IPHONE (required!)
369
+
370
+ A pulsing Bluetooth icon in the status bar indicates logging is active.
371
+ """)
372
+
373
+ input("Press Enter when your iPhone is ready with the logging profile installed...")
374
+
375
+ print()
376
+ print("-" * 60)
377
+ print("CAPTURE INSTRUCTIONS")
378
+ print("-" * 60)
379
+ print(f"""
380
+ 1. PacketLogger will now open
381
+ 2. Go to File > New iOS Trace (or Cmd+Shift+I)
382
+ 3. Select your iPhone from the device list
383
+ 4. Click 'Start' to begin capturing
384
+ 5. On your iPhone:
385
+ - Open your robot's control app (e.g., WonderBot)
386
+ - Connect to the robot
387
+ - Perform the actions you want to capture
388
+ - Disconnect from the robot
389
+ 6. In PacketLogger, click 'Stop'
390
+ 7. Save the capture to: {output_path}
391
+ (File > Save As...)
392
+ """)
393
+
394
+ # Open PacketLogger
395
+ print("Opening PacketLogger...")
396
+ subprocess.run(["open", packet_logger])
397
+
398
+ print()
399
+ input(f"Press Enter after you've saved the capture to {output_path}...")
400
+
401
+ # Verify the file exists
402
+ if os.path.exists(output_path):
403
+ size = os.path.getsize(output_path)
404
+ print(f"\n✓ Capture saved: {output_path} ({size:,} bytes)")
405
+ return True
406
+ else:
407
+ print(f"\n✗ Capture file not found at {output_path}")
408
+ print(" Make sure you saved the file to the correct location.")
409
+ return False
410
+
411
+
412
+ def capture_android_interactive(output_path: str) -> bool:
413
+ """
414
+ Guide user through Android BLE capture using HCI snoop log.
415
+
416
+ Returns True if capture was successful.
417
+ """
418
+ print("=" * 60)
419
+ print("ANDROID BLE CAPTURE WORKFLOW")
420
+ print("=" * 60)
421
+ print()
422
+
423
+ # Check prerequisites
424
+ print("Checking prerequisites...")
425
+
426
+ adb = check_adb()
427
+ if adb:
428
+ print(f" ✓ adb found at {adb}")
429
+ else:
430
+ print(" ✗ adb not found")
431
+ print(" Install with: brew install android-platform-tools")
432
+ return False
433
+
434
+ android_ok, android_msg = check_android_device()
435
+ if android_ok:
436
+ print(f" ✓ {android_msg}")
437
+ else:
438
+ print(f" ✗ {android_msg}")
439
+ print()
440
+ print("To enable USB debugging:")
441
+ print(" 1. Go to Settings > About Phone")
442
+ print(" 2. Tap 'Build Number' 7 times")
443
+ print(" 3. Go to Settings > Developer Options")
444
+ print(" 4. Enable 'USB Debugging'")
445
+ print(" 5. Connect phone and accept the debugging prompt")
446
+ return False
447
+
448
+ tshark = check_tshark()
449
+ if tshark:
450
+ print(f" ✓ tshark found at {tshark}")
451
+ else:
452
+ print(" ⚠ tshark not found (needed for analysis)")
453
+ print(" Install with: brew install wireshark")
454
+
455
+ print()
456
+ print("-" * 60)
457
+ print("ENABLE BLUETOOTH HCI SNOOP LOG")
458
+ print("-" * 60)
459
+ print("""
460
+ On your Android phone:
461
+ 1. Go to Settings > Developer Options
462
+ 2. Find 'Enable Bluetooth HCI snoop log'
463
+ (may be under 'Networking' section)
464
+ 3. ENABLE it
465
+ 4. RESTART your phone (required for logging to start!)
466
+
467
+ After restart, the logging will be active.
468
+ """)
469
+
470
+ input("Press Enter when you've enabled HCI snoop log and restarted...")
471
+
472
+ print()
473
+ print("-" * 60)
474
+ print("CAPTURE INSTRUCTIONS")
475
+ print("-" * 60)
476
+ print("""
477
+ Now perform the actions you want to capture:
478
+
479
+ 1. Open your robot's control app (e.g., WonderBot)
480
+ 2. Connect to the robot
481
+ 3. Perform the actions you want to capture
482
+ 4. Disconnect from the robot
483
+ 5. DISABLE Bluetooth HCI snoop log in Developer Options
484
+ (This stops logging and finalizes the file)
485
+ """)
486
+
487
+ input("Press Enter when you've finished capturing and disabled the snoop log...")
488
+
489
+ print()
490
+ print("Extracting capture via adb bugreport...")
491
+ print("(This may take 1-2 minutes)")
492
+
493
+ # Create temp directory for extraction
494
+ with tempfile.TemporaryDirectory() as tmpdir:
495
+ bugreport_zip = os.path.join(tmpdir, "bugreport.zip")
496
+
497
+ try:
498
+ # Get bugreport
499
+ result = subprocess.run(
500
+ [adb, "bugreport", bugreport_zip],
501
+ capture_output=True,
502
+ text=True,
503
+ timeout=180, # 3 minutes timeout
504
+ )
505
+
506
+ if not os.path.exists(bugreport_zip):
507
+ print(f"✗ Failed to get bugreport: {result.stderr}")
508
+ return False
509
+
510
+ print(f" ✓ Bugreport saved ({os.path.getsize(bugreport_zip):,} bytes)")
511
+
512
+ # Extract and find btsnoop file
513
+ extract_dir = os.path.join(tmpdir, "extracted")
514
+ subprocess.run(
515
+ ["unzip", "-q", bugreport_zip, "-d", extract_dir],
516
+ check=True,
517
+ )
518
+
519
+ # Find btsnoop files
520
+ btsnoop_files = []
521
+ for root, dirs, files in os.walk(extract_dir):
522
+ for f in files:
523
+ if "btsnoop" in f.lower() or f.endswith(".cfa"):
524
+ btsnoop_files.append(os.path.join(root, f))
525
+
526
+ if not btsnoop_files:
527
+ print("✗ No btsnoop file found in bugreport")
528
+ print(" Make sure you enabled HCI snoop log before capturing")
529
+ return False
530
+
531
+ # Use the largest file (most data)
532
+ btsnoop_file = max(btsnoop_files, key=os.path.getsize)
533
+ print(f" ✓ Found: {os.path.basename(btsnoop_file)}")
534
+
535
+ # Copy to output
536
+ shutil.copy2(btsnoop_file, output_path)
537
+ print(f" ✓ Saved to: {output_path}")
538
+
539
+ return True
540
+
541
+ except subprocess.TimeoutExpired:
542
+ print("✗ Timeout waiting for bugreport")
543
+ return False
544
+ except Exception as e:
545
+ print(f"✗ Error: {e}")
546
+ return False
547
+
548
+
549
+ def analyze_capture(capture_path: str) -> Optional[CaptureAnalysis]:
550
+ """
551
+ Analyze a BLE capture file (.pklg or .btsnoop).
552
+
553
+ Uses tshark to extract BLE ATT protocol data.
554
+ """
555
+ if not os.path.exists(capture_path):
556
+ print(f"Error: File not found: {capture_path}")
557
+ return None
558
+
559
+ tshark = check_tshark()
560
+ if not tshark:
561
+ print("Error: tshark not found. Install with: brew install wireshark")
562
+ return None
563
+
564
+ print(f"Analyzing: {capture_path}")
565
+ print(f"Size: {os.path.getsize(capture_path):,} bytes")
566
+ print()
567
+
568
+ # Extract BLE ATT data
569
+ try:
570
+ result = subprocess.run(
571
+ [
572
+ tshark,
573
+ "-r", capture_path,
574
+ "-Y", "btatt.value",
575
+ "-T", "fields",
576
+ "-e", "frame.number",
577
+ "-e", "btatt.opcode",
578
+ "-e", "btatt.handle",
579
+ "-e", "btatt.value",
580
+ ],
581
+ capture_output=True,
582
+ text=True,
583
+ timeout=60,
584
+ )
585
+ except subprocess.TimeoutExpired:
586
+ print("Error: tshark timeout")
587
+ return None
588
+ except Exception as e:
589
+ print(f"Error running tshark: {e}")
590
+ return None
591
+
592
+ if result.returncode != 0:
593
+ print(f"tshark error: {result.stderr}")
594
+ return None
595
+
596
+ # Parse output
597
+ analysis = CaptureAnalysis(file_path=capture_path)
598
+
599
+ for line in result.stdout.strip().split("\n"):
600
+ if not line.strip():
601
+ continue
602
+
603
+ parts = line.split("\t")
604
+ if len(parts) < 4:
605
+ continue
606
+
607
+ frame, opcode, handle_str, value_hex = parts[0], parts[1], parts[2], parts[3]
608
+
609
+ if not value_hex:
610
+ continue
611
+
612
+ # Parse handle
613
+ try:
614
+ if handle_str.startswith("0x"):
615
+ handle = int(handle_str, 16)
616
+ else:
617
+ handle = int(handle_str)
618
+ except:
619
+ continue
620
+
621
+ # Decode value
622
+ try:
623
+ value_bytes = bytes.fromhex(value_hex.replace(":", ""))
624
+ decoded = value_bytes.decode("ascii")
625
+ except:
626
+ decoded = None
627
+
628
+ cmd = BLECommand(
629
+ frame=int(frame),
630
+ opcode=opcode,
631
+ handle=handle,
632
+ raw_hex=value_hex,
633
+ decoded=decoded,
634
+ )
635
+ analysis.commands.append(cmd)
636
+
637
+ # Track writes and notifies
638
+ display = decoded if decoded else f"[hex:{value_hex[:20]}...]"
639
+
640
+ if cmd.is_write:
641
+ if analysis.write_handle is None:
642
+ analysis.write_handle = handle
643
+ analysis.unique_writes[display] = analysis.unique_writes.get(display, 0) + 1
644
+ elif cmd.is_notify:
645
+ if analysis.notify_handle is None:
646
+ analysis.notify_handle = handle
647
+ analysis.unique_notifies[display] = analysis.unique_notifies.get(display, 0) + 1
648
+
649
+ # Detect protocol
650
+ for cmd in analysis.unique_writes.keys():
651
+ if cmd.startswith("CMD|") and cmd.endswith("|$"):
652
+ analysis.protocol_detected = "HiWonder CMD Protocol"
653
+ break
654
+ elif cmd.startswith("[hex:5555"):
655
+ analysis.protocol_detected = "Binary Servo Protocol (0x55 0x55)"
656
+ break
657
+
658
+ return analysis
659
+
660
+
661
+ def inspect_ble_device(address: str) -> bool:
662
+ """
663
+ Inspect a BLE device's services and characteristics.
664
+
665
+ Shows which characteristics support write vs notify to help
666
+ users understand the correct configuration.
667
+ """
668
+ try:
669
+ import asyncio
670
+ from bleak import BleakClient, BleakScanner
671
+ except ImportError:
672
+ print("Error: bleak not installed. Run: pip install bleak")
673
+ return False
674
+
675
+ async def _inspect():
676
+ print(f"Scanning for device: {address}")
677
+
678
+ # Try to find device
679
+ device = None
680
+ devices = await BleakScanner.discover(timeout=5.0)
681
+ for d in devices:
682
+ if d.address == address or (d.name and address.lower() in d.name.lower()):
683
+ device = d
684
+ break
685
+
686
+ if not device:
687
+ print(f"Device not found: {address}")
688
+ print("\nAvailable devices:")
689
+ for d in devices:
690
+ if d.name:
691
+ print(f" {d.name}: {d.address}")
692
+ return False
693
+
694
+ print(f"Found: {device.name} ({device.address})")
695
+ print()
696
+
697
+ async with BleakClient(device, timeout=15.0) as client:
698
+ print("=" * 60)
699
+ print("BLE SERVICE & CHARACTERISTIC MAP")
700
+ print("=" * 60)
701
+ print()
702
+
703
+ for service in client.services:
704
+ # Shorten UUID for display
705
+ uuid = str(service.uuid)
706
+ short_uuid = uuid.split("-")[0] if "-" in uuid else uuid
707
+ print(f"Service: {short_uuid}")
708
+
709
+ for char in service.characteristics:
710
+ char_uuid = str(char.uuid)
711
+ char_short = char_uuid.split("-")[0] if "-" in char_uuid else char_uuid
712
+
713
+ props = char.properties
714
+ prop_str = ", ".join(props)
715
+
716
+ # Determine usage
717
+ usage = []
718
+ if "write" in props or "write-without-response" in props:
719
+ usage.append("← WRITE commands here")
720
+ if "notify" in props or "indicate" in props:
721
+ usage.append("→ SUBSCRIBE for responses")
722
+ if "read" in props:
723
+ usage.append("? Can read")
724
+
725
+ print(f" ├── {char_short}")
726
+ print(f" │ Handle: 0x{char.handle:04x} ({char.handle})")
727
+ print(f" │ Properties: [{prop_str}]")
728
+ if usage:
729
+ print(f" │ {' | '.join(usage)}")
730
+ print(f" │")
731
+
732
+ print()
733
+ print("=" * 60)
734
+ print("RECOMMENDED CONFIGURATION")
735
+ print("=" * 60)
736
+
737
+ # Find write and notify characteristics
738
+ write_chars = []
739
+ notify_chars = []
740
+
741
+ for service in client.services:
742
+ for char in service.characteristics:
743
+ if "write" in char.properties or "write-without-response" in char.properties:
744
+ write_chars.append(char)
745
+ if "notify" in char.properties or "indicate" in char.properties:
746
+ notify_chars.append(char)
747
+
748
+ if write_chars:
749
+ print(f"\nWrite commands to:")
750
+ for c in write_chars:
751
+ print(f" {c.uuid}")
752
+
753
+ if notify_chars:
754
+ print(f"\nSubscribe for responses on:")
755
+ for c in notify_chars:
756
+ print(f" {c.uuid}")
757
+
758
+ if write_chars and notify_chars:
759
+ print(f"""
760
+ Example Python code:
761
+
762
+ FFE1_WRITE = "{write_chars[0].uuid}"
763
+ FFE2_NOTIFY = "{notify_chars[0].uuid}"
764
+
765
+ await client.start_notify(FFE2_NOTIFY, handler)
766
+ await client.write_gatt_char(FFE1_WRITE, b"your_command")
767
+ """)
768
+
769
+ return True
770
+
771
+ return asyncio.run(_inspect())
772
+
773
+
774
+ async def ble_pickup_sequence(address: str) -> bool:
775
+ """
776
+ Execute a ball pickup sequence using pure BLE commands.
777
+
778
+ Uses:
779
+ - Action groups (CMD|2|1|X|$) for arm control (if available)
780
+ - Locomotion (CMD|3|X|$) for walking
781
+
782
+ Note: Action groups are pre-programmed sequences on the robot.
783
+ Not all robots have pickup-related action groups. If the arm
784
+ doesn't respond, use USB serial for direct servo control.
785
+
786
+ Returns True if sequence completed, False if failed.
787
+ """
788
+ try:
789
+ import asyncio
790
+ from bleak import BleakClient, BleakScanner
791
+ except ImportError:
792
+ print("Error: bleak not installed. Run: pip install bleak")
793
+ return False
794
+
795
+ FFE1_WRITE = "0000ffe1-0000-1000-8000-00805f9b34fb"
796
+ FFE2_NOTIFY = "0000ffe2-0000-1000-8000-00805f9b34fb"
797
+
798
+ responses = []
799
+
800
+ def notification_handler(sender, data):
801
+ try:
802
+ decoded = data.decode('ascii')
803
+ responses.append(decoded)
804
+ print(f" ✓ Response: {decoded}")
805
+ except:
806
+ responses.append(data.hex())
807
+
808
+ print(f"Scanning for device: {address}...")
809
+
810
+ # Find device
811
+ devices = await BleakScanner.discover(timeout=5.0)
812
+ device = None
813
+ for d in devices:
814
+ if d.address == address or (d.name and address.lower() in (d.name or "").lower()):
815
+ device = d
816
+ break
817
+
818
+ if not device:
819
+ print(f"Device not found: {address}")
820
+ print("Make sure the robot is powered on and not connected to another app.")
821
+ return False
822
+
823
+ print(f"Found: {device.name} ({device.address})")
824
+
825
+ try:
826
+ async with BleakClient(device, timeout=15.0) as client:
827
+ print(f"Connected: {client.is_connected}")
828
+
829
+ # Subscribe to notifications
830
+ await client.start_notify(FFE2_NOTIFY, notification_handler)
831
+ await asyncio.sleep(0.3)
832
+
833
+ # Battery check
834
+ print("\n--- Battery Check ---")
835
+ await client.write_gatt_char(FFE1_WRITE, b"CMD|6|$")
836
+ await asyncio.sleep(0.5)
837
+
838
+ for resp in responses:
839
+ if "CMD|6|" in resp:
840
+ parts = resp.split("|")
841
+ if len(parts) >= 3:
842
+ try:
843
+ mv = int(parts[2])
844
+ print(f" Battery: {mv/1000:.2f}V")
845
+ except:
846
+ pass
847
+
848
+ # Try action groups to find arm-related sequences
849
+ # These are pre-programmed on the robot
850
+ print("\n--- Testing Action Groups (looking for arm movement) ---")
851
+ print("Watch the robot's arm for any movement...")
852
+
853
+ # Action groups 1-5 - one might be arm-related
854
+ for action_id in [1, 2, 3, 4, 5]:
855
+ cmd = f"CMD|2|1|{action_id}|$".encode()
856
+ print(f"\n Action Group {action_id}: {cmd.decode()}")
857
+ await client.write_gatt_char(FFE1_WRITE, cmd)
858
+ await asyncio.sleep(3.0)
859
+
860
+ # Now do the locomotion sequence
861
+ print("\n--- Locomotion Sequence ---")
862
+
863
+ # Walk forward
864
+ print("\n Walking forward (1.5s)...")
865
+ await client.write_gatt_char(FFE1_WRITE, b"CMD|3|3|$")
866
+ await asyncio.sleep(1.5)
867
+
868
+ # Stop
869
+ print(" Stopping...")
870
+ await client.write_gatt_char(FFE1_WRITE, b"CMD|3|0|$")
871
+ await asyncio.sleep(0.5)
872
+
873
+ # Walk backward
874
+ print(" Walking backward (1.0s)...")
875
+ await client.write_gatt_char(FFE1_WRITE, b"CMD|3|7|$")
876
+ await asyncio.sleep(1.0)
877
+
878
+ # Stop
879
+ print(" Stopping...")
880
+ await client.write_gatt_char(FFE1_WRITE, b"CMD|3|0|$")
881
+ await asyncio.sleep(0.5)
882
+
883
+ # Reset posture
884
+ print(" Resetting posture...")
885
+ await client.write_gatt_char(FFE1_WRITE, b"CMD|1|5|$")
886
+ await asyncio.sleep(1.0)
887
+
888
+ await client.stop_notify(FFE2_NOTIFY)
889
+
890
+ print("\n--- Sequence Complete ---")
891
+ print("\nIf the arm didn't move during action groups, the robot may")
892
+ print("not have pre-programmed arm sequences. Use USB serial for")
893
+ print("direct servo control: ate robot test mechdog --port <port>")
894
+
895
+ return True
896
+
897
+ except Exception as e:
898
+ print(f"Error: {e}")
899
+ return False
900
+
901
+
902
+ # CLI Integration
903
+ def register_cli_commands(cli):
904
+ """Register BLE capture commands with the CLI."""
905
+ import click
906
+
907
+ @cli.group()
908
+ def ble():
909
+ """BLE protocol capture and analysis tools."""
910
+ pass
911
+
912
+ @ble.command("capture")
913
+ @click.option("--platform", "-p", type=click.Choice(["ios", "android", "auto"]),
914
+ default="auto", help="Mobile platform")
915
+ @click.option("--output", "-o", default="capture.pklg", help="Output file path")
916
+ def ble_capture(platform: str, output: str):
917
+ """Capture BLE traffic from phone app for protocol analysis."""
918
+ if platform == "auto":
919
+ # Try to detect
920
+ ios_ok, _ = check_ios_device()
921
+ android_ok, _ = check_android_device()
922
+
923
+ if ios_ok:
924
+ platform = "ios"
925
+ elif android_ok:
926
+ platform = "android"
927
+ else:
928
+ print("No mobile device detected.")
929
+ print("Connect your iPhone or Android phone via USB.")
930
+ return
931
+
932
+ if platform == "ios":
933
+ success = capture_ios_interactive(output)
934
+ else:
935
+ success = capture_android_interactive(output)
936
+
937
+ if success:
938
+ print()
939
+ print(f"Next step: Analyze the capture with:")
940
+ print(f" ate ble analyze {output}")
941
+
942
+ @ble.command("analyze")
943
+ @click.argument("capture_file")
944
+ @click.option("--generate", "-g", is_flag=True, help="Generate Python replay code")
945
+ @click.option("--output", "-o", help="Output file for generated code")
946
+ def ble_analyze(capture_file: str, generate: bool, output: str):
947
+ """Analyze a BLE capture file to decode the protocol."""
948
+ analysis = analyze_capture(capture_file)
949
+
950
+ if not analysis:
951
+ return
952
+
953
+ print(analysis.summary())
954
+
955
+ if generate or output:
956
+ code = analysis.generate_python_code()
957
+
958
+ if output:
959
+ with open(output, "w") as f:
960
+ f.write(code)
961
+ print(f"\nGenerated code saved to: {output}")
962
+ else:
963
+ print("\n" + "=" * 60)
964
+ print("GENERATED PYTHON CODE")
965
+ print("=" * 60)
966
+ print(code)
967
+
968
+ @ble.command("inspect")
969
+ @click.argument("address")
970
+ def ble_inspect(address: str):
971
+ """Inspect a BLE device's characteristics and capabilities."""
972
+ inspect_ble_device(address)
973
+
974
+ return ble
975
+
976
+
977
+ if __name__ == "__main__":
978
+ # Quick test
979
+ import sys
980
+
981
+ if len(sys.argv) > 1:
982
+ if sys.argv[1] == "analyze" and len(sys.argv) > 2:
983
+ analysis = analyze_capture(sys.argv[2])
984
+ if analysis:
985
+ print(analysis.summary())
986
+ print("\n" + analysis.generate_python_code())
987
+ elif sys.argv[1] == "inspect" and len(sys.argv) > 2:
988
+ inspect_ble_device(sys.argv[2])
989
+ elif sys.argv[1] == "capture":
990
+ platform = sys.argv[2] if len(sys.argv) > 2 else "ios"
991
+ output = sys.argv[3] if len(sys.argv) > 3 else "capture.pklg"
992
+ if platform == "ios":
993
+ capture_ios_interactive(output)
994
+ else:
995
+ capture_android_interactive(output)
996
+ else:
997
+ print("Usage:")
998
+ print(" python ble_capture.py capture [ios|android] [output.pklg]")
999
+ print(" python ble_capture.py analyze <capture_file>")
1000
+ print(" python ble_capture.py inspect <device_address>")