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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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
|