openmotion-sdk 1.5.5__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 (79) hide show
  1. omotion/CommInterface.py +370 -0
  2. omotion/CommandError.py +12 -0
  3. omotion/Console.py +2459 -0
  4. omotion/ConsoleTelemetry.py +369 -0
  5. omotion/DFUProgrammer.py +346 -0
  6. omotion/DualMotionComposite.py +198 -0
  7. omotion/FPGAProgrammer.py +567 -0
  8. omotion/GitHubReleases.py +158 -0
  9. omotion/Interface.py +441 -0
  10. omotion/MotionComposite.py +137 -0
  11. omotion/MotionConfig.py +180 -0
  12. omotion/MotionProcessing.py +1779 -0
  13. omotion/MotionSignal.py +45 -0
  14. omotion/MotionUart.py +467 -0
  15. omotion/ScanWorkflow.py +1166 -0
  16. omotion/Sensor.py +1063 -0
  17. omotion/StreamInterface.py +369 -0
  18. omotion/USBInterfaceBase.py +41 -0
  19. omotion/UartPacket.py +108 -0
  20. omotion/__init__.py +51 -0
  21. omotion/_vendor/libusb/windows/x64/libusb-1.0.dll +0 -0
  22. omotion/_vendor/libusb/windows/x86/libusb-1.0.dll +0 -0
  23. omotion/config.py +219 -0
  24. omotion/connection_state.py +9 -0
  25. omotion/dfu-util/AUTHORS +49 -0
  26. omotion/dfu-util/COPYING +340 -0
  27. omotion/dfu-util/ChangeLog +143 -0
  28. omotion/dfu-util/README +21 -0
  29. omotion/dfu-util/README-bin.txt +16 -0
  30. omotion/dfu-util/darwin-x86_64/dfu-prefix +0 -0
  31. omotion/dfu-util/darwin-x86_64/dfu-suffix +0 -0
  32. omotion/dfu-util/darwin-x86_64/dfu-util +0 -0
  33. omotion/dfu-util/darwin-x86_64/libusb-1.0.0.dylib +0 -0
  34. omotion/dfu-util/darwin-x86_64/libusb-1.0.a +0 -0
  35. omotion/dfu-util/darwin-x86_64/libusb-1.0.dylib +0 -0
  36. omotion/dfu-util/darwin-x86_64/libusb-1.0.la +41 -0
  37. omotion/dfu-util/darwin-x86_64/lsusb +0 -0
  38. omotion/dfu-util/darwin-x86_64/usbhid-dump +0 -0
  39. omotion/dfu-util/dfu-prefix.1.man.pdf +0 -0
  40. omotion/dfu-util/dfu-suffix.1.man.pdf +0 -0
  41. omotion/dfu-util/dfu-util.1.man.pdf +0 -0
  42. omotion/dfu-util/dfuse-pack.py +352 -0
  43. omotion/dfu-util/linux-amd64/dfu-prefix +0 -0
  44. omotion/dfu-util/linux-amd64/dfu-suffix +0 -0
  45. omotion/dfu-util/linux-amd64/dfu-util +0 -0
  46. omotion/dfu-util/linux-amd64/dfu-util-static +0 -0
  47. omotion/dfu-util/lsusb_build_on_mingw.patch +225 -0
  48. omotion/dfu-util/win32/dfu-prefix.exe +0 -0
  49. omotion/dfu-util/win32/dfu-suffix.exe +0 -0
  50. omotion/dfu-util/win32/dfu-util-static.exe +0 -0
  51. omotion/dfu-util/win32/dfu-util.exe +0 -0
  52. omotion/dfu-util/win32/libusb-1.0.a +0 -0
  53. omotion/dfu-util/win32/libusb-1.0.dll +0 -0
  54. omotion/dfu-util/win32/libusb-1.0.dll.a +0 -0
  55. omotion/dfu-util/win32/libusb-1.0.la +41 -0
  56. omotion/dfu-util/win32/lsusb-static.exe +0 -0
  57. omotion/dfu-util/win32/lsusb.exe +0 -0
  58. omotion/dfu-util/win64/dfu-prefix.exe +0 -0
  59. omotion/dfu-util/win64/dfu-suffix.exe +0 -0
  60. omotion/dfu-util/win64/dfu-util-static.exe +0 -0
  61. omotion/dfu-util/win64/dfu-util.exe +0 -0
  62. omotion/dfu-util/win64/libusb-1.0.a +0 -0
  63. omotion/dfu-util/win64/libusb-1.0.dll +0 -0
  64. omotion/dfu-util/win64/libusb-1.0.dll.a +0 -0
  65. omotion/dfu-util/win64/libusb-1.0.la +41 -0
  66. omotion/dfu-util/win64/lsusb-static.exe +0 -0
  67. omotion/dfu-util/win64/lsusb.exe +0 -0
  68. omotion/i2c_data_packet.py +134 -0
  69. omotion/i2c_packet.py +104 -0
  70. omotion/i2c_status_packet.py +82 -0
  71. omotion/jedecParser.py +313 -0
  72. omotion/signal_wrapper.py +40 -0
  73. omotion/usb_backend.py +51 -0
  74. omotion/utils.py +324 -0
  75. openmotion_sdk-1.5.5.dist-info/METADATA +51 -0
  76. openmotion_sdk-1.5.5.dist-info/RECORD +79 -0
  77. openmotion_sdk-1.5.5.dist-info/WHEEL +5 -0
  78. openmotion_sdk-1.5.5.dist-info/licenses/LICENSE +661 -0
  79. openmotion_sdk-1.5.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,370 @@
1
+ import logging
2
+ import omotion.config as config
3
+ from omotion.UartPacket import UartPacket
4
+ from omotion.config import (
5
+ OW_ACK,
6
+ OW_CMD_NOP,
7
+ OW_END_BYTE,
8
+ OW_START_BYTE,
9
+ OW_DATA,
10
+ OW_CMD_ECHO,
11
+ )
12
+ import usb.core
13
+ import usb.util
14
+ import time
15
+ import threading
16
+ import queue
17
+ from omotion.USBInterfaceBase import USBInterfaceBase
18
+ from omotion import _log_root
19
+
20
+ # Max data_len we accept (sanity check to avoid runaway buffer)
21
+ OW_MAX_PACKET_DATA_LEN = 4096 * 2
22
+
23
+ logger = logging.getLogger(
24
+ f"{_log_root}.CommInterface" if _log_root else "CommInterface"
25
+ )
26
+
27
+ # Max data_len we accept (sanity check to avoid runaway buffer)
28
+ OW_MAX_PACKET_DATA_LEN = 4096 * 2
29
+
30
+ _PACKET_TYPE_NAMES = {
31
+ value: name
32
+ for name, value in vars(config).items()
33
+ if name.startswith("OW_") and name.isupper() and isinstance(value, int)
34
+ }
35
+ _CMD_NAMES = {
36
+ "OW_CMD": {
37
+ value: name
38
+ for name, value in vars(config).items()
39
+ if name.startswith("OW_CMD_")
40
+ },
41
+ "OW_CONTROLLER": {
42
+ value: name
43
+ for name, value in vars(config).items()
44
+ if name.startswith("OW_CTRL_")
45
+ },
46
+ "OW_FPGA": {
47
+ value: name
48
+ for name, value in vars(config).items()
49
+ if name.startswith("OW_FPGA_")
50
+ },
51
+ "OW_CAMERA": {
52
+ value: name
53
+ for name, value in vars(config).items()
54
+ if name.startswith("OW_CAMERA_")
55
+ },
56
+ "OW_IMU": {
57
+ value: name
58
+ for name, value in vars(config).items()
59
+ if name.startswith("OW_IMU_")
60
+ },
61
+ }
62
+
63
+
64
+ def _format_named(value: int, name_map: dict[int, str], width: int = 2) -> str:
65
+ name = name_map.get(value)
66
+ if name:
67
+ return f"{name}(0x{value:0{width}X})"
68
+ return f"0x{value:0{width}X}"
69
+
70
+
71
+ # =========================================
72
+ # Comm Interface (IN + OUT + threads)
73
+ # =========================================
74
+ class CommInterface(USBInterfaceBase):
75
+ def __init__(self, dev, interface_index, desc="Comm", async_mode=False):
76
+ super().__init__(dev, interface_index, desc)
77
+ self.read_thread = None
78
+ self.stop_event = threading.Event()
79
+ # Contiguous byte buffer: USB reader extends the end, packet parser chops from the front
80
+ self._read_buffer = bytearray()
81
+ self._buffer_lock = threading.Lock()
82
+ self._buffer_condition = threading.Condition(self._buffer_lock)
83
+ self.packet_count = 0
84
+ self.async_mode = async_mode
85
+ self.on_disconnect = None
86
+ self._disconnect_notified = False
87
+ self._io_lock = threading.RLock()
88
+ self._send_lock = threading.Lock()
89
+ if self.async_mode:
90
+ self.response_queue = queue.Queue()
91
+ self.response_thread = threading.Thread(
92
+ target=self._process_responses, daemon=True
93
+ )
94
+ self.response_thread.start()
95
+
96
+ def claim(self):
97
+ super().claim()
98
+ intf = self.dev.get_active_configuration()[(self.interface_index, 0)]
99
+ self.ep_out = usb.util.find_descriptor(
100
+ intf,
101
+ custom_match=lambda e: (
102
+ usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
103
+ ),
104
+ )
105
+ if not self.ep_out:
106
+ raise RuntimeError(f"{self.desc}: No OUT endpoint found")
107
+
108
+ def send_packet(
109
+ self,
110
+ id=None,
111
+ packetType=OW_ACK,
112
+ command=OW_CMD_NOP,
113
+ addr=0,
114
+ reserved=0,
115
+ data=None,
116
+ timeout=10.0,
117
+ max_retries=0,
118
+ ) -> UartPacket:
119
+ with self._send_lock:
120
+ if id is None:
121
+ self.packet_count = (self.packet_count + 1) & 0xFFFF or 1
122
+ id = self.packet_count
123
+
124
+ if data:
125
+ if not isinstance(data, (bytes, bytearray)):
126
+ raise ValueError("Data must be bytes or bytearray")
127
+ payload = data
128
+ else:
129
+ payload = b""
130
+
131
+ uart_packet = UartPacket(
132
+ id=id,
133
+ packet_type=packetType,
134
+ command=command,
135
+ addr=addr,
136
+ reserved=reserved,
137
+ data=payload,
138
+ )
139
+
140
+ tx_bytes = uart_packet.to_bytes()
141
+ packet_type_name = _PACKET_TYPE_NAMES.get(packetType)
142
+ cmd_names = _CMD_NAMES.get(packet_type_name, {})
143
+ logger.debug(
144
+ f"{self.desc}: TX id=0x{id:04X} "
145
+ f"type={_format_named(packetType, _PACKET_TYPE_NAMES)} "
146
+ f"cmd={_format_named(command, cmd_names)} "
147
+ f"addr=0x{addr:02X} reserved=0x{reserved:02X} len={len(payload)} data={tx_bytes.hex()}"
148
+ )
149
+
150
+ self.write(tx_bytes)
151
+ time.sleep(0.0005)
152
+
153
+ if not self.async_mode:
154
+ start = time.monotonic()
155
+ data = bytearray()
156
+ with self._io_lock:
157
+ while time.monotonic() - start < timeout:
158
+ try:
159
+ resp = self.receive()
160
+ time.sleep(0.005)
161
+ if resp:
162
+ data.extend(resp)
163
+ if data and data[-1] == OW_END_BYTE:
164
+ return UartPacket(buffer=data)
165
+ except usb.core.USBError:
166
+ continue
167
+ last_error = TimeoutError("No response")
168
+ else:
169
+ start_time = time.monotonic()
170
+ while time.monotonic() - start_time < timeout:
171
+ if self.response_queue.empty():
172
+ time.sleep(0.0005)
173
+ else:
174
+ time.sleep(0.001)
175
+ pkt = self.response_queue.get()
176
+ if pkt.id != id:
177
+ logger.warning(
178
+ "%s: discarding stale response id=0x%04X (expected 0x%04X)",
179
+ self.desc, pkt.id, id,
180
+ )
181
+ continue
182
+ return pkt
183
+ raise TimeoutError(f"No response in async mode, packet id 0x{id:04X}")
184
+
185
+ def clear_buffer(self):
186
+ with self._buffer_lock:
187
+ self._read_buffer.clear()
188
+
189
+ def write(self, data, timeout=100, _retries=5):
190
+ with self._io_lock:
191
+ for attempt in range(1 + _retries):
192
+ try:
193
+ return self.dev.write(self.ep_out.bEndpointAddress, data, timeout=timeout)
194
+ except usb.core.USBError as e:
195
+ # Firmware back-pressure: the device's OUT FIFO is temporarily
196
+ # full. Back off briefly and retry so callers don't have to
197
+ # care about transient busy periods (e.g. after program_fpga).
198
+ if e.errno in (110, 10060): # ETIMEDOUT / WSAETIMEDOUT
199
+ if attempt < _retries:
200
+ delay = 0.05 * (attempt + 1) # 50 ms, 100 ms, 150 ms …
201
+ logger.warning(
202
+ "%s: write timeout (attempt %d/%d), retrying in %.0f ms",
203
+ self.desc, attempt + 1, 1 + _retries, delay * 1000,
204
+ )
205
+ time.sleep(delay)
206
+ continue
207
+ logger.error("%s: write timed out after %d attempts", self.desc, 1 + _retries)
208
+ raise
209
+ # A stalled endpoint (EPIPE / broken-pipe) can be recovered by
210
+ # issuing a CLEAR_HALT control transfer. Try once; if it works
211
+ # re-send the original data. Any other USB error is re-raised
212
+ # so callers and _read_loop disconnect logic see it normally.
213
+ if e.errno in (32, -9): # EPIPE on Linux; LIBUSB_ERROR_PIPE cross-platform
214
+ logger.warning("%s: OUT endpoint stalled, attempting clear_halt", self.desc)
215
+ try:
216
+ usb.util.clear_halt(self.dev, self.ep_out)
217
+ return self.dev.write(self.ep_out.bEndpointAddress, data, timeout=timeout)
218
+ except Exception as recovery_err:
219
+ logger.error("%s: clear_halt recovery failed: %s", self.desc, recovery_err)
220
+ raise
221
+
222
+ def receive(self, length=512, timeout=100):
223
+ with self._io_lock:
224
+ data = self.dev.read(self.ep_in.bEndpointAddress, length, timeout=timeout)
225
+ logger.debug(f"Received {len(data)} bytes.")
226
+ return data
227
+
228
+ def start_read_thread(self):
229
+ if self.read_thread and self.read_thread.is_alive():
230
+ logger.info(f"{self.desc}: Read thread already running")
231
+ return
232
+ self.stop_event.clear()
233
+ self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
234
+ self.read_thread.start()
235
+ logger.info(f"{self.desc}: Read thread started")
236
+
237
+ def stop_read_thread(self):
238
+ self.stop_event.set()
239
+ # We're intentionally shutting down; prevent the read loop from
240
+ # triggering disconnect callbacks/logging if the device disappears.
241
+ self._disconnect_notified = True
242
+ if self.read_thread:
243
+ # Safety net: _trigger_disconnect now dispatches on_disconnect to a
244
+ # separate daemon thread, so this guard should never fire in normal
245
+ # operation. It stays here to prevent a hang if anyone calls
246
+ # stop_read_thread() from a context where the read thread is on the
247
+ # call stack (joining the current thread raises RuntimeError).
248
+ if threading.current_thread() is not self.read_thread:
249
+ self.read_thread.join(timeout=2.0)
250
+ logger.info(f"{self.desc}: Read thread stopped")
251
+
252
+ def _trigger_disconnect(self, error):
253
+ # During an intentional shutdown, ignore disconnect triggers.
254
+ if self.stop_event.is_set():
255
+ return
256
+ if self._disconnect_notified:
257
+ return
258
+ self._disconnect_notified = True
259
+ logger.error(f"{self.desc}: triggering disconnect due to USB error: {error}")
260
+ self.stop_event.set()
261
+ if callable(self.on_disconnect):
262
+ # Dispatch the disconnect callback to a new daemon thread rather than
263
+ # calling it directly from the read thread. on_disconnect typically
264
+ # calls MotionComposite.disconnect() → stop_read_thread() → join(),
265
+ # which would deadlock if run on the read thread itself. By handing
266
+ # off to a separate thread the read loop exits naturally (stop_event
267
+ # is already set) and the join in stop_read_thread() completes quickly.
268
+ t = threading.Thread(
269
+ target=self.on_disconnect,
270
+ args=(self.desc, error),
271
+ daemon=True,
272
+ name=f"{self.desc}-disconnect",
273
+ )
274
+ t.start()
275
+
276
+ def _read_loop(self):
277
+ while not self.stop_event.is_set():
278
+ try:
279
+ data = self.dev.read(
280
+ self.ep_in.bEndpointAddress, self.ep_in.wMaxPacketSize, timeout=100
281
+ )
282
+ if data:
283
+ data_bytes = bytes(data)
284
+ with self._buffer_condition:
285
+ self._read_buffer.extend(data_bytes)
286
+ self._buffer_condition.notify()
287
+ logger.debug(f"Read {len(data)} bytes.")
288
+ time.sleep(0.001)
289
+ except usb.core.USBError as e:
290
+ # If we're shutting down, USB errors here are expected and should not be noisy.
291
+ if self.stop_event.is_set():
292
+ break
293
+ if e.errno == 110:
294
+ pass
295
+ elif e.errno == 10060:
296
+ pass
297
+ elif e.errno == 32:
298
+ # Only log at ERROR when this is unexpected (not a clean shutdown).
299
+ if not self._disconnect_notified:
300
+ logger.error(f"{self.desc} read error: DISCONNECT{e}")
301
+ self._trigger_disconnect(e)
302
+ break
303
+
304
+ elif e.errno == 19 or e.errno == 5:
305
+ # errno 19 = ENODEV (device unplugged or GC'd during shutdown).
306
+ # errno 5 = EIO (device I/O error).
307
+ # Both are expected when the app is tearing down — only log at
308
+ # ERROR when the disconnect is genuinely unintentional.
309
+ if not self._disconnect_notified:
310
+ logger.error(f"{self.desc} read error: IO Error{e}")
311
+ self._trigger_disconnect(e)
312
+ break
313
+ else:
314
+ if not self._disconnect_notified:
315
+ logger.error(f"{self.desc} read error: Unknown Error{e}")
316
+ self._trigger_disconnect(e)
317
+ break
318
+
319
+ def _process_responses(self):
320
+ while not self.stop_event.is_set():
321
+ with self._buffer_condition:
322
+ if not self._read_buffer:
323
+ self._buffer_condition.wait(timeout=0.1)
324
+ continue
325
+ buf = self._read_buffer
326
+ # Align to start of packet: discard leading bytes until OW_START_BYTE
327
+ if buf[0] != OW_START_BYTE:
328
+ try:
329
+ start_idx = buf.index(OW_START_BYTE)
330
+ except ValueError:
331
+ start_idx = len(buf)
332
+ del self._read_buffer[:start_idx]
333
+ if start_idx == len(buf):
334
+ continue
335
+ buf = self._read_buffer
336
+ # Need at least 9 bytes to read data_len (bytes 7:9)
337
+ if len(buf) < 9:
338
+ continue
339
+ data_len = int.from_bytes(buf[7:9], "big")
340
+ if data_len > OW_MAX_PACKET_DATA_LEN:
341
+ del self._read_buffer[:1]
342
+ continue
343
+ packet_len = 12 + data_len # header(11) + data + crc(2) + end(1)
344
+ if len(buf) < packet_len:
345
+ continue
346
+ if buf[packet_len - 1] != OW_END_BYTE:
347
+ del self._read_buffer[:1]
348
+ continue
349
+ packet_bytes = bytes(buf[:packet_len])
350
+ del self._read_buffer[:packet_len]
351
+ try:
352
+ uart_packet = UartPacket(buffer=packet_bytes)
353
+ except ValueError:
354
+ continue
355
+ if (
356
+ uart_packet.id == 0
357
+ and uart_packet.packet_type == OW_DATA
358
+ and uart_packet.command == OW_CMD_ECHO
359
+ ):
360
+ _raw = bytes(uart_packet.data[:uart_packet.data_len]) if uart_packet.data_len > 0 else b""
361
+ try:
362
+ _text = _raw.decode("utf-8", errors="replace").rstrip("\x00").strip()
363
+ except Exception:
364
+ _text = ""
365
+ if _text:
366
+ logger.warning("[%s PRINTF] %s", self.desc, _text)
367
+ else:
368
+ logger.warning("[%s] MCU echo: data=%s", self.desc, _raw.hex() if _raw else "")
369
+ else:
370
+ self.response_queue.put(uart_packet)
@@ -0,0 +1,12 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class CommandError(RuntimeError):
5
+ """
6
+ Raised when the hardware returns a non-OK response (NAK, BAD_CRC, etc.)
7
+ or when the response payload cannot be decoded.
8
+ """
9
+
10
+ def __init__(self, message: str, response: Optional[Any] = None) -> None:
11
+ super().__init__(message)
12
+ self.response = response