foodforthought-cli 0.2.8__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.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +12 -0
- ate/behaviors/approach.py +399 -0
- ate/cli.py +855 -4551
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +399 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +18 -6
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +360 -24
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +16 -0
- ate/interfaces/base.py +2 -0
- ate/interfaces/sensors.py +247 -0
- ate/llm_proxy.py +239 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +42 -3
- ate/recording/session.py +12 -2
- ate/recording/visual.py +416 -0
- ate/robot/__init__.py +142 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +88 -3
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +143 -11
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +104 -2
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +6 -0
- ate/robot/registry.py +5 -2
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +285 -3
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +9 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +1 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.8.dist-info/RECORD +0 -73
- {foodforthought_cli-0.2.8.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.8.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))
|
ate/robot/calibration.py
CHANGED
|
@@ -20,29 +20,110 @@ from typing import Dict, List, Optional, Tuple, Any, Callable
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from enum import Enum
|
|
22
22
|
|
|
23
|
+
# Import unified types - use JointRole for semantic joint naming
|
|
24
|
+
from ate.robot.types import JointRole, JointType as MechanicalJointType, format_valid_options
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
class JointType(Enum):
|
|
25
|
-
"""
|
|
28
|
+
"""
|
|
29
|
+
Types of joints/servos - semantic role-based naming.
|
|
30
|
+
|
|
31
|
+
DEPRECATED: This enum combines mechanical types with semantic roles.
|
|
32
|
+
New code should use:
|
|
33
|
+
- ate.robot.types.JointType for mechanical types (revolute, prismatic, etc.)
|
|
34
|
+
- ate.robot.types.JointRole for semantic roles (shoulder_lift, gripper, etc.)
|
|
35
|
+
|
|
36
|
+
This enum is kept for backwards compatibility and accepts both.
|
|
37
|
+
"""
|
|
26
38
|
UNKNOWN = "unknown"
|
|
39
|
+
|
|
40
|
+
# Standard URDF mechanical types (for backwards compatibility)
|
|
41
|
+
REVOLUTE = "revolute"
|
|
42
|
+
PRISMATIC = "prismatic"
|
|
43
|
+
CONTINUOUS = "continuous"
|
|
44
|
+
FIXED = "fixed"
|
|
45
|
+
|
|
27
46
|
# Locomotion
|
|
28
47
|
HIP_ROLL = "hip_roll"
|
|
29
48
|
HIP_PITCH = "hip_pitch"
|
|
30
49
|
KNEE = "knee"
|
|
31
50
|
ANKLE = "ankle"
|
|
51
|
+
|
|
32
52
|
# Arm
|
|
33
53
|
SHOULDER_PAN = "shoulder_pan"
|
|
34
54
|
SHOULDER_LIFT = "shoulder_lift"
|
|
35
55
|
ELBOW = "elbow"
|
|
36
56
|
WRIST_ROLL = "wrist_roll"
|
|
37
57
|
WRIST_PITCH = "wrist_pitch"
|
|
58
|
+
|
|
38
59
|
# Gripper
|
|
39
60
|
GRIPPER = "gripper"
|
|
61
|
+
|
|
40
62
|
# Body
|
|
41
63
|
HEAD_PAN = "head_pan"
|
|
42
64
|
HEAD_TILT = "head_tilt"
|
|
43
65
|
BODY_PITCH = "body_pitch"
|
|
44
66
|
BODY_ROLL = "body_roll"
|
|
45
67
|
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_string(cls, value: str) -> "JointType":
|
|
70
|
+
"""
|
|
71
|
+
Parse joint type from string with intelligent fallbacks.
|
|
72
|
+
|
|
73
|
+
Accepts both mechanical types (revolute, prismatic) and
|
|
74
|
+
semantic roles (shoulder_lift, gripper). Provides helpful
|
|
75
|
+
suggestions on unrecognized values.
|
|
76
|
+
"""
|
|
77
|
+
if not value:
|
|
78
|
+
return cls.UNKNOWN
|
|
79
|
+
|
|
80
|
+
normalized = value.lower().strip().replace("-", "_").replace(" ", "_")
|
|
81
|
+
|
|
82
|
+
# Direct match
|
|
83
|
+
for member in cls:
|
|
84
|
+
if member.value == normalized:
|
|
85
|
+
return member
|
|
86
|
+
|
|
87
|
+
# Common aliases for mechanical types
|
|
88
|
+
aliases = {
|
|
89
|
+
"rotary": cls.REVOLUTE,
|
|
90
|
+
"rotation": cls.REVOLUTE,
|
|
91
|
+
"linear": cls.PRISMATIC,
|
|
92
|
+
"sliding": cls.PRISMATIC,
|
|
93
|
+
"wheel": cls.CONTINUOUS,
|
|
94
|
+
"static": cls.FIXED,
|
|
95
|
+
"rigid": cls.FIXED,
|
|
96
|
+
# Arm shortcuts
|
|
97
|
+
"shoulder": cls.SHOULDER_LIFT,
|
|
98
|
+
"arm_shoulder": cls.SHOULDER_LIFT,
|
|
99
|
+
"arm_elbow": cls.ELBOW,
|
|
100
|
+
# Gripper variations
|
|
101
|
+
"claw": cls.GRIPPER,
|
|
102
|
+
"hand": cls.GRIPPER,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if normalized in aliases:
|
|
106
|
+
return aliases[normalized]
|
|
107
|
+
|
|
108
|
+
# Fuzzy match
|
|
109
|
+
for member in cls:
|
|
110
|
+
if member.value in normalized or normalized in member.value:
|
|
111
|
+
return member
|
|
112
|
+
|
|
113
|
+
# Log warning but don't crash
|
|
114
|
+
import sys
|
|
115
|
+
valid_vals = [m.value for m in cls if m != cls.UNKNOWN]
|
|
116
|
+
print(f"Warning: Unknown joint type '{value}'. Using 'unknown'. "
|
|
117
|
+
f"Valid options: {', '.join(valid_vals[:8])}...",
|
|
118
|
+
file=sys.stderr)
|
|
119
|
+
|
|
120
|
+
return cls.UNKNOWN
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def valid_values(cls) -> List[str]:
|
|
124
|
+
"""Return list of valid values for error messages."""
|
|
125
|
+
return [m.value for m in cls if m != cls.UNKNOWN]
|
|
126
|
+
|
|
46
127
|
|
|
47
128
|
@dataclass
|
|
48
129
|
class ServoCalibration:
|
|
@@ -188,12 +269,16 @@ class RobotCalibration:
|
|
|
188
269
|
notes=data.get("notes", ""),
|
|
189
270
|
)
|
|
190
271
|
|
|
191
|
-
# Load servos
|
|
272
|
+
# Load servos with forgiving joint type parsing
|
|
192
273
|
for sid, sdata in data.get("servos", {}).items():
|
|
274
|
+
# Use from_string for flexible joint type parsing
|
|
275
|
+
joint_type_str = sdata.get("joint_type", "unknown")
|
|
276
|
+
joint_type = JointType.from_string(joint_type_str)
|
|
277
|
+
|
|
193
278
|
cal.servos[int(sid)] = ServoCalibration(
|
|
194
279
|
servo_id=sdata["servo_id"],
|
|
195
280
|
name=sdata["name"],
|
|
196
|
-
joint_type=
|
|
281
|
+
joint_type=joint_type,
|
|
197
282
|
min_value=sdata.get("min_value", 0),
|
|
198
283
|
max_value=sdata.get("max_value", 4096),
|
|
199
284
|
center_value=sdata.get("center_value", 2048),
|