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.
- 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 +100 -0
- ate/behaviors/approach.py +399 -0
- ate/behaviors/common.py +686 -0
- ate/behaviors/tree.py +454 -0
- ate/cli.py +855 -3995
- 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 +39 -0
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +942 -0
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +187 -0
- ate/interfaces/base.py +273 -0
- ate/interfaces/body.py +267 -0
- ate/interfaces/detection.py +282 -0
- ate/interfaces/locomotion.py +422 -0
- ate/interfaces/manipulation.py +408 -0
- ate/interfaces/navigation.py +389 -0
- ate/interfaces/perception.py +362 -0
- ate/interfaces/sensors.py +247 -0
- ate/interfaces/types.py +371 -0
- ate/llm_proxy.py +239 -0
- ate/mcp_server.py +387 -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 +83 -0
- ate/recording/demonstration.py +378 -0
- ate/recording/session.py +415 -0
- ate/recording/upload.py +304 -0
- ate/recording/visual.py +416 -0
- ate/recording/wrapper.py +95 -0
- ate/robot/__init__.py +221 -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 +668 -0
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +3735 -0
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +441 -0
- ate/robot/introspection.py +330 -0
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/manager.py +270 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +281 -0
- ate/robot/registry.py +322 -0
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +675 -0
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +1048 -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.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {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))
|