mesofield 0.3.2b0__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 (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -0,0 +1,498 @@
1
+ """Authorable base classes for non-Qt hardware devices.
2
+
3
+ Layered hierarchy that lets a user write a new device with as little
4
+ boilerplate as possible:
5
+
6
+ ``BaseDevice``
7
+ Lifecycle skeleton, ``self.signals`` (DeviceSignals), logger, cfg
8
+ storage, ``is_primary`` / ``is_running`` tracking, default
9
+ ``status``/``metadata``.
10
+
11
+ ``BaseDataProducer(BaseDevice)``
12
+ Adds the DataProducer surface: ``output_path``/``metadata_path``,
13
+ a thread-safe in-memory buffer, a ``record(payload, ts=None)``
14
+ helper that timestamps + buffers + emits ``signals.data`` in one
15
+ call, and a default CSV ``save_data`` / ``get_data``.
16
+
17
+ ``BaseSerialDevice(BaseDataProducer)``
18
+ Specialization for line-oriented serial protocols (Teensy /
19
+ Arduino). Owns its own daemon read thread; subclasses only need
20
+ to override ``parse_line(line)``. A ``development_mode`` flag
21
+ skips opening the port so authors can iterate without hardware
22
+ connected.
23
+
24
+ Qt devices (``QThread`` subclasses) cannot inherit from these due to
25
+ metaclass conflicts; they continue to duck-type the contract by
26
+ instantiating ``self.signals = DeviceSignals()`` directly.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import csv
32
+ import threading
33
+ import time
34
+ from datetime import datetime
35
+ from pathlib import Path
36
+ from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple
37
+
38
+ from mesofield.signals import DeviceSignals
39
+ from mesofield.utils._logger import get_logger
40
+
41
+ if TYPE_CHECKING:
42
+ from mesofield.config import ExperimentConfig
43
+
44
+
45
+ __all__ = [
46
+ "BaseDevice",
47
+ "BaseDataProducer",
48
+ "BaseSerialDevice",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # BaseDevice
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ class BaseDevice:
58
+ """Default lifecycle skeleton for non-Qt hardware devices.
59
+
60
+ Constructor accepts an optional ``cfg`` mapping (the YAML stanza
61
+ for this device). Common keys are auto-extracted:
62
+
63
+ - ``id`` / ``device_id`` -> ``self.device_id``
64
+ - ``primary: true`` -> ``self.is_primary``
65
+
66
+ A logger is created automatically as
67
+ ``f"{module}.{class}[{device_id}]"``.
68
+ """
69
+
70
+ device_type: ClassVar[str] = "device"
71
+ device_id: str = "device"
72
+
73
+ def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
74
+ self.cfg: Dict[str, Any] = dict(cfg or {})
75
+ self.cfg.update(kwargs)
76
+
77
+ cfg_id = self.cfg.get("id") or self.cfg.get("device_id")
78
+ if cfg_id:
79
+ self.device_id = str(cfg_id)
80
+ self.is_primary: bool = bool(self.cfg.get("primary", False))
81
+
82
+ # Injected once by HardwareManager.initialize() so producers can reach
83
+ # `make_path` and experiment state outside the per-run `arm(config)`.
84
+ self.config: Optional["ExperimentConfig"] = None
85
+
86
+ self.signals = DeviceSignals()
87
+
88
+ self._started: Optional[datetime] = None
89
+ self._stopped: Optional[datetime] = None
90
+ self.is_running: bool = False
91
+
92
+ self.logger = get_logger(
93
+ f"{self.__class__.__module__}.{self.__class__.__name__}[{self.device_id}]"
94
+ )
95
+
96
+ # -- lifecycle (subclasses override as needed) -----------------------
97
+ def initialize(self) -> bool:
98
+ return True
99
+
100
+ def arm(self, config: "ExperimentConfig") -> None: # noqa: D401 - default no-op
101
+ """Per-run preparation. No-op by default."""
102
+ return None
103
+
104
+ def start(self) -> bool:
105
+ self._started = datetime.now()
106
+ self.is_running = True
107
+ self.signals.started.emit()
108
+ return True
109
+
110
+ def stop(self) -> bool:
111
+ self._stopped = datetime.now()
112
+ self.is_running = False
113
+ self.signals.finished.emit()
114
+ return True
115
+
116
+ def shutdown(self) -> None:
117
+ return None
118
+
119
+ # -- introspection ---------------------------------------------------
120
+ def status(self) -> Dict[str, Any]:
121
+ return {
122
+ "device_id": self.device_id,
123
+ "device_type": self.device_type,
124
+ "is_primary": self.is_primary,
125
+ "is_running": self.is_running,
126
+ "started": self._started.isoformat() if self._started else None,
127
+ "stopped": self._stopped.isoformat() if self._stopped else None,
128
+ }
129
+
130
+ @property
131
+ def metadata(self) -> Dict[str, Any]:
132
+ return {
133
+ "device_id": self.device_id,
134
+ "device_type": self.device_type,
135
+ "is_primary": self.is_primary,
136
+ }
137
+
138
+ # Keys that describe orchestration / GUI / routing rather than calibration.
139
+ # Stripped from `cfg` when building the `calibration` payload that ends up
140
+ # in the AcquisitionManifest. Subclasses can extend this tuple.
141
+ _MANIFEST_RESERVED_KEYS: ClassVar[tuple[str, ...]] = (
142
+ "id", "device_id", "type", "primary", "widgets", "output",
143
+ )
144
+
145
+ @property
146
+ def calibration(self) -> Dict[str, Any]:
147
+ """Device-specific constants worth recording with the data.
148
+
149
+ Default: everything in `cfg` that isn't an orchestration key. Override
150
+ on a subclass to curate the list explicitly.
151
+ """
152
+ reserved = set(self._MANIFEST_RESERVED_KEYS)
153
+ return {k: v for k, v in self.cfg.items() if k not in reserved}
154
+
155
+ def sidecars(self) -> list:
156
+ """Auxiliary files this device writes alongside its primary output.
157
+
158
+ Default: none. Override to declare extra sidecars (masks, regions,
159
+ derived parameter files) so they ride in the manifest with a `role`
160
+ and `schema_version` instead of being discovered by glob.
161
+
162
+ The camera classes' per-frame metadata JSON is the primary sidecar
163
+ and lives on `self.metadata_path` -- not here. Use this method for
164
+ the *extra* files only.
165
+
166
+ Returns a list of `mesokit_schema.SidecarEntry`-shaped mappings or
167
+ instances. The Procedure relativises any absolute paths.
168
+ """
169
+ return []
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # BaseDataProducer
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ class BaseDataProducer(BaseDevice):
178
+ """Base class for devices that stream samples to the DataQueue.
179
+
180
+ Subclasses produce data by calling :meth:`record`, which
181
+ timestamps, appends to an in-memory buffer, and emits
182
+ ``signals.data(payload, ts)`` in one step.
183
+
184
+ The default :meth:`save_data` writes the buffer as a two-column
185
+ CSV (``timestamp,payload``). Override for binary or
186
+ domain-specific formats.
187
+ """
188
+
189
+ file_type: ClassVar[str] = "csv"
190
+ bids_type: ClassVar[Optional[str]] = "beh"
191
+ sampling_rate: float = 0.0
192
+ data_type: ClassVar[str] = "samples"
193
+ # Declared clock source for the AcquisitionManifest's TimeBasis. Matches
194
+ # what `record()` stamps (`time.time()`). Camera subclasses override.
195
+ clock_source: ClassVar[str] = "wall_unix_s"
196
+ # Typed contract for what this producer pushes onto the session-wide
197
+ # dataqueue (see mesokit_schema.DataqueuePayloadSchema). Set on a
198
+ # subclass when the parser needs to locate this producer's rows in
199
+ # dataqueue.csv. Leave `None` for producers that don't write to the
200
+ # queue (e.g. cameras whose alignment is per-frame metadata, not queue
201
+ # rows). The Procedure copies the value into ProducerEntry.dataqueue_schema.
202
+ dataqueue_payload_schema: ClassVar[Optional[dict]] = None
203
+
204
+ def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
205
+ super().__init__(cfg, **kwargs)
206
+
207
+ self.output_path: Optional[str] = None
208
+ self.metadata_path: Optional[str] = None
209
+ self.is_active: bool = False
210
+
211
+ self._buffer: list[Tuple[float, Any]] = []
212
+ self._buffer_lock = threading.Lock()
213
+
214
+ # -- helpers ---------------------------------------------------------
215
+ def record(self, payload: Any, ts: Optional[float] = None) -> float:
216
+ """Buffer ``payload`` and emit ``signals.data``.
217
+
218
+ Returns the timestamp used.
219
+ """
220
+ if ts is None:
221
+ ts = time.time()
222
+ with self._buffer_lock:
223
+ self._buffer.append((ts, payload))
224
+ try:
225
+ self.signals.data.emit(payload, ts)
226
+ except Exception as exc:
227
+ self.logger.warning(f"signals.data emit failed: {exc}")
228
+ return ts
229
+
230
+ def clear_buffer(self) -> None:
231
+ with self._buffer_lock:
232
+ self._buffer.clear()
233
+
234
+ # -- lifecycle overrides --------------------------------------------
235
+ def arm(self, config: "ExperimentConfig") -> None:
236
+ """Default ``arm``: clear buffer and resolve ``output_path``.
237
+
238
+ ``config`` is expected to expose ``make_path(name, ext, bids)``
239
+ (see :class:`mesofield.config.ExperimentConfig`).
240
+ """
241
+ self.clear_buffer()
242
+ make_path = getattr(config, "make_path", None)
243
+ if make_path is not None:
244
+ try:
245
+ self.output_path = make_path(
246
+ self.device_id, self.file_type, self.bids_type
247
+ )
248
+ except Exception as exc:
249
+ self.logger.debug(f"make_path failed for {self.device_id}: {exc}")
250
+
251
+ def start(self) -> bool:
252
+ self.is_active = True
253
+ return super().start()
254
+
255
+ def stop(self) -> bool:
256
+ self.is_active = False
257
+ return super().stop()
258
+
259
+ # -- data access -----------------------------------------------------
260
+ def get_data(self) -> list[Tuple[float, Any]]:
261
+ with self._buffer_lock:
262
+ return list(self._buffer)
263
+
264
+ def save_data(self, path: Optional[str] = None) -> Optional[str]:
265
+ target = path or self.output_path
266
+ if not target:
267
+ self.logger.debug(f"save_data: no path provided for {self.device_id}")
268
+ return None
269
+
270
+ snapshot = self.get_data()
271
+ Path(target).parent.mkdir(parents=True, exist_ok=True)
272
+
273
+ # If any payload is a dict, fan keys out to columns.
274
+ keys: list[str] = []
275
+ seen: set[str] = set()
276
+ for _ts, p in snapshot:
277
+ if isinstance(p, dict):
278
+ for k in p:
279
+ if k not in seen:
280
+ seen.add(k)
281
+ keys.append(k)
282
+
283
+ with open(target, "w", newline="", encoding="utf-8") as fh:
284
+ writer = csv.writer(fh)
285
+ if keys:
286
+ writer.writerow(["timestamp", *keys])
287
+ for ts, payload in snapshot:
288
+ if isinstance(payload, dict):
289
+ writer.writerow([ts, *(payload.get(k, "") for k in keys)])
290
+ else:
291
+ writer.writerow([ts, payload, *([""] * (len(keys) - 1))])
292
+ else:
293
+ writer.writerow(["timestamp", "payload"])
294
+ for ts, payload in snapshot:
295
+ writer.writerow([ts, payload])
296
+ self.logger.debug(f"Saved {len(snapshot)} samples to {target}")
297
+ return target
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # BaseSerialDevice
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ class BaseSerialDevice(BaseDataProducer):
306
+ """Polling device for line-based serial protocols (Arduino/Teensy/etc.).
307
+
308
+ Subclasses override :meth:`parse_line`. Optionally override
309
+ :meth:`setup_serial` for post-open initialisation (handshakes,
310
+ buffer drain, configuring device-side parameters).
311
+
312
+ Configuration keys read from ``cfg``:
313
+
314
+ - ``port`` (str, required when ``development_mode=False``)
315
+ - ``baudrate`` (int, default 115200)
316
+ - ``timeout`` (float, default 0.1) — pyserial readline timeout.
317
+ - ``dtr`` (bool | None, default None) — set to ``False`` to
318
+ suppress Arduino auto-reset on connect. ``None`` keeps the
319
+ OS default.
320
+ - ``connect_delay`` (float, default 0.0) — seconds to wait after
321
+ opening the port before reads begin; common Arduinos need ~2.0.
322
+ The input buffer is flushed after the delay.
323
+ - ``development_mode`` (bool, default False) — skip opening the
324
+ port so the GUI / Procedure can launch without hardware.
325
+ ``send_line`` becomes a no-op; the polling thread idles.
326
+ """
327
+
328
+ default_baudrate: ClassVar[int] = 115_200
329
+ default_timeout: ClassVar[float] = 0.1
330
+ poll_interval: float = 0.0
331
+ join_timeout: float = 2.0
332
+
333
+ def __init__(self, cfg: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
334
+ super().__init__(cfg, **kwargs)
335
+ self.port: Optional[str] = self.cfg.get("port")
336
+ self.baudrate: int = int(self.cfg.get("baudrate", self.default_baudrate))
337
+ self.timeout: float = float(self.cfg.get("timeout", self.default_timeout))
338
+ self.dtr: Optional[bool] = self.cfg.get("dtr")
339
+ self.connect_delay: float = float(self.cfg.get("connect_delay", 0.0))
340
+ self.development_mode: bool = bool(self.cfg.get("development_mode", False))
341
+
342
+ self._serial: Any = None
343
+ self._serial_lock = threading.Lock()
344
+ self._stop_event = threading.Event()
345
+ self._thread: Optional[threading.Thread] = None
346
+
347
+ # -- subclass hooks -------------------------------------------------
348
+ def parse_line(self, line: bytes) -> Optional[Tuple[Any, Optional[float]]]:
349
+ """Decode one raw serial line into ``(payload, ts)`` or ``None``."""
350
+ raise NotImplementedError("Subclasses must override parse_line()")
351
+
352
+ def setup_serial(self) -> None:
353
+ """Hook called once after the port is opened. Default no-op.
354
+
355
+ Override to send a handshake, query firmware version, configure
356
+ device-side parameters, etc.
357
+ """
358
+ return None
359
+
360
+ # -- write path -----------------------------------------------------
361
+ def send_line(self, payload: str | bytes, *, newline: bytes = b"\n") -> bytes:
362
+ """Write a command to the device. Thread-safe with the reader.
363
+
364
+ Accepts ``str`` (UTF-8 encoded) or ``bytes``. ``newline`` is
365
+ appended unless ``payload`` already ends with it. In
366
+ ``development_mode`` the bytes are logged and discarded.
367
+ Returns the bytes that were written (or would have been).
368
+ """
369
+ data = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
370
+ if not data.endswith(newline):
371
+ data += newline
372
+
373
+ if self.development_mode:
374
+ self.logger.debug(f"send_line (dev_mode, no-op): {data!r}")
375
+ return data
376
+ if self._serial is None:
377
+ raise RuntimeError(
378
+ f"{self.device_id}: cannot send_line before initialize()/start()"
379
+ )
380
+ with self._serial_lock:
381
+ self._serial.write(data)
382
+ return data
383
+
384
+ # -- lifecycle ------------------------------------------------------
385
+ def initialize(self) -> bool:
386
+ if self.development_mode or self._serial is not None:
387
+ return True
388
+ if not self.port:
389
+ raise ValueError(
390
+ f"{self.device_id}: 'port' is required when development_mode is False"
391
+ )
392
+ try:
393
+ import serial
394
+ except ImportError as exc: # pragma: no cover
395
+ raise ImportError(
396
+ "pyserial is required for BaseSerialDevice; install with `pip install pyserial`"
397
+ ) from exc
398
+
399
+ try:
400
+ if self.dtr is None:
401
+ ser = serial.Serial(self.port, baudrate=self.baudrate, timeout=self.timeout)
402
+ else:
403
+ # DTR must be set before the port physically opens
404
+ # (Arduino-no-reset idiom).
405
+ ser = serial.Serial(baudrate=self.baudrate, timeout=self.timeout)
406
+ ser.port = self.port
407
+ ser.dtr = self.dtr
408
+ ser.open()
409
+ self._serial = ser
410
+
411
+ if self.connect_delay > 0:
412
+ time.sleep(self.connect_delay)
413
+ try:
414
+ ser.reset_input_buffer()
415
+ except Exception:
416
+ pass
417
+ except Exception as exc:
418
+ raise RuntimeError(f"Failed to open serial port {self.port}: {exc}") from exc
419
+
420
+ self.logger.info(f"Opened serial {self.port} @ {self.baudrate} baud")
421
+ try:
422
+ self.setup_serial()
423
+ except Exception:
424
+ self.logger.exception("setup_serial failed; continuing")
425
+ return True
426
+
427
+ def shutdown(self) -> None:
428
+ if self.is_running:
429
+ self.stop()
430
+ super().shutdown()
431
+ if self._serial is not None:
432
+ try:
433
+ self._serial.close()
434
+ except Exception as exc:
435
+ self.logger.debug(f"serial close failed: {exc}")
436
+ self._serial = None
437
+
438
+ def start(self) -> bool:
439
+ if self._serial is None and not self.development_mode:
440
+ self.initialize()
441
+ if self._thread is not None and self._thread.is_alive():
442
+ self.logger.debug("start() called but thread already running")
443
+ return False
444
+ self._stop_event.clear()
445
+ self._thread = threading.Thread(
446
+ target=self._run_loop,
447
+ name=f"{self.__class__.__name__}-{self.device_id}",
448
+ daemon=True,
449
+ )
450
+ self._thread.start()
451
+ return super().start()
452
+
453
+ def stop(self) -> bool:
454
+ self._stop_event.set()
455
+ thread = self._thread
456
+ if thread is not None:
457
+ thread.join(timeout=self.join_timeout)
458
+ if thread.is_alive():
459
+ self.logger.warning(
460
+ f"read thread for {self.device_id} did not exit within {self.join_timeout:.1f}s"
461
+ )
462
+ self._thread = None
463
+ return super().stop()
464
+
465
+ # -- read loop ------------------------------------------------------
466
+ def _run_loop(self) -> None:
467
+ try:
468
+ while not self._stop_event.is_set():
469
+ try:
470
+ sample = self._read_once()
471
+ except Exception as exc:
472
+ self.logger.exception(f"read raised: {exc}")
473
+ sample = None
474
+
475
+ if sample is not None:
476
+ payload, ts = sample
477
+ self.record(payload, ts)
478
+ elif self.poll_interval > 0:
479
+ self._stop_event.wait(self.poll_interval)
480
+ finally:
481
+ self.logger.debug("read loop exited")
482
+
483
+ def _read_once(self) -> Optional[Tuple[Any, Optional[float]]]:
484
+ if self._serial is None:
485
+ return None
486
+ try:
487
+ with self._serial_lock:
488
+ line = self._serial.readline()
489
+ except Exception as exc:
490
+ self.logger.exception(f"serial read failed: {exc}")
491
+ return None
492
+ if not line:
493
+ return None
494
+ try:
495
+ return self.parse_line(line)
496
+ except Exception as exc:
497
+ self.logger.exception(f"parse_line raised on {line!r}: {exc}")
498
+ return None