labmcp-labjack 0.1.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.
- labmcp_labjack/__init__.py +1 -0
- labmcp_labjack/driver.py +656 -0
- labmcp_labjack/server.py +481 -0
- labmcp_labjack/simulator.py +455 -0
- labmcp_labjack-0.1.0.dist-info/METADATA +126 -0
- labmcp_labjack-0.1.0.dist-info/RECORD +8 -0
- labmcp_labjack-0.1.0.dist-info/WHEEL +4 -0
- labmcp_labjack-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LabMCP server for LabJack T-series DAQ devices (T4, T7/T7-Pro, T8) via the LJM library."""
|
labmcp_labjack/driver.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""Driver for LabJack T-series DAQ devices (T4, T7, T7-Pro, T8) through LabJack's LJM library.
|
|
2
|
+
|
|
3
|
+
The driver only uses the documented LJM "e" functions of the official ``labjack-ljm`` Python
|
|
4
|
+
wrapper (``openS``, ``getHandleInfo``, ``eReadNames``, ``eWriteNames``, ``namesToAddresses``,
|
|
5
|
+
``eStreamStart/Read/Stop``) and register names from the T-series Modbus map.
|
|
6
|
+
|
|
7
|
+
References:
|
|
8
|
+
|
|
9
|
+
* LabJack T-Series Datasheet, https://support.labjack.com/docs/t-series-datasheet
|
|
10
|
+
(3.2 Stream Mode, 13.0 Digital I/O, 14.0 Analog Inputs and the per-device pages 14.3.0-14.3.2,
|
|
11
|
+
14.1.1 Thermocouple, 15.0 DAC, 18.0 Internal Temp Sensor, Appendix A-3 noise/resolution).
|
|
12
|
+
* T-series Modbus map ``ljm_constants.json``, https://github.com/labjack/ljm_constants
|
|
13
|
+
(register names and types, and the per-device valid values of ``AIN#_RANGE`` and
|
|
14
|
+
``AIN#_RESOLUTION_INDEX``).
|
|
15
|
+
* ``labjack-ljm`` 1.23 Python wrapper and LabJack's official examples,
|
|
16
|
+
https://github.com/labjack/labjack-ljm-python (``Examples/More/Stream/stream_basic.py``,
|
|
17
|
+
``Examples/More/AIN/single_ain_with_config.py``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import math
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, replace
|
|
28
|
+
from typing import Any, Literal
|
|
29
|
+
|
|
30
|
+
from labmcp import InstrumentConnectionError, InstrumentError, InstrumentProtocolError
|
|
31
|
+
from labmcp.audit import AuditLog
|
|
32
|
+
|
|
33
|
+
DT_T4, DT_T7, DT_T8 = 4, 7, 8
|
|
34
|
+
CONNECTION_NAMES = {1: "USB", 2: "TCP", 3: "Ethernet", 4: "WiFi"}
|
|
35
|
+
#: LJM/firmware sentinel meaning "no valid value" (skipped stream scans, open thermocouples).
|
|
36
|
+
DUMMY_VALUE = -9999.0
|
|
37
|
+
#: Largest stream the driver will hold in memory (samples summed over all channels).
|
|
38
|
+
MAX_STREAM_SAMPLES = 2_000_000
|
|
39
|
+
|
|
40
|
+
#: AIN#_EF_INDEX values of the thermocouple extended feature (datasheet 14.1.1, T7/T8 only).
|
|
41
|
+
THERMOCOUPLE_EF_INDEX = {"E": 20, "J": 21, "K": 22, "R": 23, "T": 24, "S": 25, "N": 27, "B": 28, "C": 30}
|
|
42
|
+
#: Modbus address of TEMPERATURE_DEVICE_K, the T7 default CJC source (datasheet 14.1.1).
|
|
43
|
+
TEMPERATURE_DEVICE_K_ADDRESS = 60052
|
|
44
|
+
#: TEMPERATURE#(0:7)_CAPTURE base address; the T8 default CJC for AIN n is 700 + 2n.
|
|
45
|
+
T8_TEMPERATURE_CAPTURE_ADDRESS = 700
|
|
46
|
+
#: LM34 CJC sensor conversion from the LabJack thermocouple app note (K/V and K).
|
|
47
|
+
LM34_SLOPE_K_PER_V, LM34_OFFSET_K = 55.56, 255.37
|
|
48
|
+
|
|
49
|
+
_CONNECTION_ERRORS = {1224, 1227, 1230, 1236, 1239, 1240, 1314}
|
|
50
|
+
_HINTS = {
|
|
51
|
+
1227: "no matching LabJack was found - check the USB/Ethernet cable, the --address identifier "
|
|
52
|
+
"and the device_type/connection_type options",
|
|
53
|
+
1230: "the device is open in another program (Kipling, LJLogM, another script) - close it first",
|
|
54
|
+
1294: "LJM does not know this register name",
|
|
55
|
+
1314: "no LabJack devices were found on any connection",
|
|
56
|
+
2370: "that input range is not available on this device",
|
|
57
|
+
2373: "differential pairs must be an even AIN with the next odd AIN as negative channel",
|
|
58
|
+
2375: "resolution index out of range for this device",
|
|
59
|
+
2501: "the line is configured as an analog input (T4 flexible I/O)",
|
|
60
|
+
2580: "this extended feature is not supported on this device",
|
|
61
|
+
2583: "the AIN extended feature is not configured on this channel",
|
|
62
|
+
2605: "a stream is running; command-response analog reads are blocked while streaming",
|
|
63
|
+
2607: "a channel in the scan list cannot be streamed",
|
|
64
|
+
2608: "scan rate x number of channels is too high for this device",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class DeviceSpec:
|
|
70
|
+
"""Capabilities of one T-series model, from the datasheet and the Modbus map."""
|
|
71
|
+
|
|
72
|
+
model: str
|
|
73
|
+
ain_channels: tuple[int, ...]
|
|
74
|
+
#: Valid AIN#_RANGE values in volts (±range). Empty for the T4, whose ranges are fixed.
|
|
75
|
+
ain_ranges_v: tuple[float, ...]
|
|
76
|
+
max_resolution_index: int
|
|
77
|
+
max_stream_resolution_index: int
|
|
78
|
+
dac_max_v: float
|
|
79
|
+
dio_lines: tuple[int, ...]
|
|
80
|
+
#: Aggregate stream sample rate (all channels), samples/s.
|
|
81
|
+
max_stream_samples_per_s: float
|
|
82
|
+
#: Highest scan rate regardless of channel count (T8: 40 kHz per channel, sampled simultaneously).
|
|
83
|
+
max_stream_scan_rate_hz: float
|
|
84
|
+
thermocouple: bool
|
|
85
|
+
#: Single-ended/differential selectable via AIN#_NEGATIVE_CH (T7 only).
|
|
86
|
+
selectable_differential: bool
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
T4 = DeviceSpec("T4", tuple(range(12)), (), 5, 5, 5.0, tuple(range(4, 20)), 50_000, 50_000, False, False)
|
|
90
|
+
T7 = DeviceSpec("T7", tuple(range(14)), (10.0, 1.0, 0.1, 0.01), 8, 8, 5.0, tuple(range(23)), 100_000, 100_000, True, True)
|
|
91
|
+
T7_PRO = replace(T7, model="T7-Pro", max_resolution_index=12)
|
|
92
|
+
T8 = DeviceSpec(
|
|
93
|
+
"T8",
|
|
94
|
+
tuple(range(8)),
|
|
95
|
+
(11.0, 9.6, 4.8, 2.4, 1.2, 0.6, 0.3, 0.15, 0.075, 0.036, 0.018),
|
|
96
|
+
16,
|
|
97
|
+
16,
|
|
98
|
+
10.0,
|
|
99
|
+
tuple(range(20)),
|
|
100
|
+
320_000,
|
|
101
|
+
40_000,
|
|
102
|
+
True,
|
|
103
|
+
False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_BANKS = (("FIO", 0, 8), ("EIO", 8, 8), ("CIO", 16, 4), ("MIO", 20, 3))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def dio_name(line: int) -> str:
|
|
110
|
+
"""Bank name of a DIO number: 0 -> FIO0, 8 -> EIO0, 16 -> CIO0, 20 -> MIO0."""
|
|
111
|
+
for bank, start, count in _BANKS:
|
|
112
|
+
if start <= line < start + count:
|
|
113
|
+
return f"{bank}{line - start}"
|
|
114
|
+
raise ValueError(f"DIO{line} does not exist on T-series devices")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dio_index(name: str | int) -> int:
|
|
118
|
+
"""Parse ``"FIO3"``, ``"EIO0"``, ``"CIO1"``, ``"MIO2"``, ``"DIO5"`` or ``5`` into a DIO number."""
|
|
119
|
+
if isinstance(name, int):
|
|
120
|
+
return name
|
|
121
|
+
text = name.strip().upper()
|
|
122
|
+
if text.isdigit():
|
|
123
|
+
return int(text)
|
|
124
|
+
if text.startswith("DIO") and text[3:].isdigit():
|
|
125
|
+
return int(text[3:])
|
|
126
|
+
for bank, start, count in _BANKS:
|
|
127
|
+
if text.startswith(bank) and text[3:].isdigit() and int(text[3:]) < count:
|
|
128
|
+
return start + int(text[3:])
|
|
129
|
+
raise InstrumentProtocolError(
|
|
130
|
+
f"{name!r} is not a digital I/O name. Use FIO0-7, EIO0-7, CIO0-3, MIO0-2 or DIO0-22."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_ljm() -> Any:
|
|
135
|
+
"""Import the official LJM wrapper (``from labjack import ljm``).
|
|
136
|
+
|
|
137
|
+
Importing it loads the native LabJackM library immediately and, if that fails, the wrapper
|
|
138
|
+
*prints* the error to stdout and continues with no library. stdout is the MCP stdio channel,
|
|
139
|
+
so it is redirected to stderr here and the missing library is turned into a clear error.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
143
|
+
from labjack import ljm
|
|
144
|
+
except ImportError as exc:
|
|
145
|
+
raise InstrumentConnectionError(
|
|
146
|
+
"The `labjack-ljm` Python package is not installed. Install it with "
|
|
147
|
+
"`pip install labjack-ljm`, or use --simulate to try the server without hardware."
|
|
148
|
+
) from exc
|
|
149
|
+
if getattr(getattr(ljm, "ljm", None), "_staticLib", None) is None:
|
|
150
|
+
raise InstrumentConnectionError(
|
|
151
|
+
"The LabJack LJM library (LabJackM) is not installed or could not be loaded. Install "
|
|
152
|
+
"LJM from https://labjack.com/support/software/installers/ljm (it also installs the "
|
|
153
|
+
"USB driver), or use --simulate to try the server without hardware."
|
|
154
|
+
)
|
|
155
|
+
return ljm
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class StreamData:
|
|
160
|
+
channels: list[int]
|
|
161
|
+
scan_rate_hz: float
|
|
162
|
+
#: One list per channel, in volts. Skipped scans (-9999) are replaced by NaN.
|
|
163
|
+
data: list[list[float]]
|
|
164
|
+
skipped_scans: int
|
|
165
|
+
max_device_backlog: int
|
|
166
|
+
max_ljm_backlog: int
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class ThermocoupleResult:
|
|
171
|
+
temperature_c: float | None
|
|
172
|
+
thermocouple_voltage_v: float
|
|
173
|
+
cjc_temperature_c: float
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class LabJackT:
|
|
177
|
+
"""One open T-series device. ``ljm`` is the ``labjack.ljm`` module (or the simulator)."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, ljm: Any, handle: int, audit: AuditLog | None = None) -> None:
|
|
180
|
+
self.ljm = ljm
|
|
181
|
+
self.handle = handle
|
|
182
|
+
self.audit = audit
|
|
183
|
+
self.lock = threading.RLock()
|
|
184
|
+
#: DIO lines this server has driven as outputs (released/driven low by `safe_state`).
|
|
185
|
+
self.driven_lines: set[int] = set()
|
|
186
|
+
#: Extra lines to include in `safe_state` (the ``safe_dio`` option).
|
|
187
|
+
self.safe_lines: set[int] = set()
|
|
188
|
+
info = self._call("getHandleInfo", ljm.getHandleInfo, handle)
|
|
189
|
+
self.device_type, self.connection_type, self.serial_number, ip, _port, _ = info
|
|
190
|
+
self.ip_address = ljm.numberToIP(ip) if ip else None
|
|
191
|
+
spec = {DT_T4: T4, DT_T7: T7, DT_T8: T8}.get(self.device_type)
|
|
192
|
+
if spec is None:
|
|
193
|
+
self.close()
|
|
194
|
+
raise InstrumentConnectionError(
|
|
195
|
+
f"LJM opened a device of type {self.device_type}, which this server does not support "
|
|
196
|
+
"(T4, T7/T7-Pro and T8 only)."
|
|
197
|
+
)
|
|
198
|
+
if spec is T7 and int(self.read_names(["HARDWARE_INSTALLED"])[0]) & 1:
|
|
199
|
+
spec = T7_PRO # bit 0 = high-resolution 24-bit ADC installed
|
|
200
|
+
self.spec = spec
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------ low level
|
|
203
|
+
|
|
204
|
+
def _log(self, message: str) -> None:
|
|
205
|
+
if self.audit is not None:
|
|
206
|
+
self.audit.event(message, "ljm")
|
|
207
|
+
|
|
208
|
+
def _call(self, what: str, fn: Any, *args: Any) -> Any:
|
|
209
|
+
try:
|
|
210
|
+
return fn(*args)
|
|
211
|
+
except self.ljm.LJMError as exc:
|
|
212
|
+
code = getattr(exc, "errorCode", None)
|
|
213
|
+
text = getattr(exc, "errorString", "") or str(exc)
|
|
214
|
+
hint = _HINTS.get(code or 0)
|
|
215
|
+
message = f"LJM error {code} ({text}) during {what}" + (f": {hint}." if hint else ".")
|
|
216
|
+
self._log(f"error: {message}")
|
|
217
|
+
if code in _CONNECTION_ERRORS:
|
|
218
|
+
raise InstrumentConnectionError(message) from exc
|
|
219
|
+
raise InstrumentProtocolError(message) from exc
|
|
220
|
+
|
|
221
|
+
def read_names(self, names: list[str]) -> list[float]:
|
|
222
|
+
with self.lock:
|
|
223
|
+
values = self._call(f"read of {', '.join(names)}", self.ljm.eReadNames, self.handle, len(names), names)
|
|
224
|
+
self._log("read " + ", ".join(f"{n}={v:.9g}" for n, v in zip(names, values, strict=True)))
|
|
225
|
+
return [float(v) for v in values]
|
|
226
|
+
|
|
227
|
+
def write_names(self, names: list[str], values: list[float]) -> None:
|
|
228
|
+
self._log("write " + ", ".join(f"{n}={v:.9g}" for n, v in zip(names, values, strict=True)))
|
|
229
|
+
with self.lock:
|
|
230
|
+
self._call(f"write of {', '.join(names)}", self.ljm.eWriteNames, self.handle, len(names), names, values)
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------ identity
|
|
233
|
+
|
|
234
|
+
def identify(self) -> dict[str, str]:
|
|
235
|
+
serial, firmware, hardware = self.read_names(["SERIAL_NUMBER", "FIRMWARE_VERSION", "HARDWARE_VERSION"])
|
|
236
|
+
info = {
|
|
237
|
+
"manufacturer": "LabJack",
|
|
238
|
+
"model": self.spec.model,
|
|
239
|
+
"serial": str(int(serial)),
|
|
240
|
+
"firmware": f"{firmware:.4f}",
|
|
241
|
+
"hardware_version": f"{hardware:.2f}",
|
|
242
|
+
"connection": CONNECTION_NAMES.get(self.connection_type, str(self.connection_type)),
|
|
243
|
+
}
|
|
244
|
+
if self.ip_address:
|
|
245
|
+
info["ip_address"] = self.ip_address
|
|
246
|
+
with contextlib.suppress(InstrumentError, AttributeError):
|
|
247
|
+
info["device_name"] = self._call(
|
|
248
|
+
"read of DEVICE_NAME_DEFAULT", self.ljm.eReadNameString, self.handle, "DEVICE_NAME_DEFAULT"
|
|
249
|
+
)
|
|
250
|
+
return info
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------ validation helpers
|
|
253
|
+
|
|
254
|
+
def _check_ain_channels(self, channels: list[int]) -> None:
|
|
255
|
+
if not channels:
|
|
256
|
+
raise InstrumentProtocolError("Give at least one analog input channel.")
|
|
257
|
+
if len(set(channels)) != len(channels):
|
|
258
|
+
raise InstrumentProtocolError(f"Channel list {channels} contains duplicates.")
|
|
259
|
+
valid = self.spec.ain_channels
|
|
260
|
+
bad = [c for c in channels if c not in valid]
|
|
261
|
+
if bad:
|
|
262
|
+
raise InstrumentProtocolError(
|
|
263
|
+
f"AIN{bad[0]} does not exist on the {self.spec.model}; valid analog inputs are "
|
|
264
|
+
f"AIN{valid[0]}-AIN{valid[-1]}."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _check_line(self, line: int) -> None:
|
|
268
|
+
if line not in self.spec.dio_lines:
|
|
269
|
+
lo, hi = self.spec.dio_lines[0], self.spec.dio_lines[-1]
|
|
270
|
+
raise InstrumentProtocolError(
|
|
271
|
+
f"DIO{line} does not exist on the {self.spec.model}; valid lines are "
|
|
272
|
+
f"{dio_name(lo)} (DIO{lo}) to {dio_name(hi)} (DIO{hi})."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _check_t4_flexible(self, channels: list[int]) -> None:
|
|
276
|
+
"""On the T4, AIN4-AIN11 share terminals with FIO4-EIO3. Reading the AIN switches the
|
|
277
|
+
line to analog, which would release a digital output that is driving something."""
|
|
278
|
+
flexible = [c for c in channels if 4 <= c <= 11]
|
|
279
|
+
if self.spec is not T4 or not flexible:
|
|
280
|
+
return
|
|
281
|
+
direction, analog = (int(v) for v in self.read_names(["DIO_DIRECTION", "DIO_ANALOG_ENABLE"]))
|
|
282
|
+
for c in flexible:
|
|
283
|
+
bit = 1 << c
|
|
284
|
+
if not analog & bit and direction & bit:
|
|
285
|
+
raise InstrumentProtocolError(
|
|
286
|
+
f"AIN{c} shares its terminal with {dio_name(c)}, which is currently a digital "
|
|
287
|
+
f"OUTPUT. Reading AIN{c} would switch the line to analog input and release "
|
|
288
|
+
"whatever it drives. Release the line first (set_outputs_safe) or use another input."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _match_range(self, range_v: float) -> float:
|
|
292
|
+
for r in self.spec.ain_ranges_v:
|
|
293
|
+
if math.isclose(r, range_v, rel_tol=1e-6):
|
|
294
|
+
return r
|
|
295
|
+
if not self.spec.ain_ranges_v:
|
|
296
|
+
raise InstrumentProtocolError(
|
|
297
|
+
"The T4's input ranges are fixed (AIN0-AIN3: ±10 V, AIN4-AIN11: 0-2.5 V); leave range_v unset."
|
|
298
|
+
)
|
|
299
|
+
valid = ", ".join(f"±{r:g}" for r in self.spec.ain_ranges_v)
|
|
300
|
+
raise InstrumentProtocolError(f"±{range_v:g} V is not a {self.spec.model} input range. Valid: {valid} V.")
|
|
301
|
+
|
|
302
|
+
def _check_differential(self, channels: list[int], differential: bool) -> None:
|
|
303
|
+
if not differential:
|
|
304
|
+
return
|
|
305
|
+
if not self.spec.selectable_differential:
|
|
306
|
+
why = "T4 inputs are single-ended only" if self.spec is T4 else "T8 inputs are always isolated differential inputs"
|
|
307
|
+
raise InstrumentProtocolError(f"differential=true is only for the T7 ({why}).")
|
|
308
|
+
for c in channels:
|
|
309
|
+
if c % 2 or c > 12:
|
|
310
|
+
raise InstrumentProtocolError(
|
|
311
|
+
f"AIN{c} cannot be a differential positive input: use an even channel 0-12; the "
|
|
312
|
+
"negative input is the next odd channel (AIN0-AIN1, AIN2-AIN3, ...)."
|
|
313
|
+
)
|
|
314
|
+
if c + 1 in channels:
|
|
315
|
+
raise InstrumentProtocolError(f"AIN{c + 1} is the negative input of AIN{c}; don't list both.")
|
|
316
|
+
|
|
317
|
+
def _t8_settle(self) -> None:
|
|
318
|
+
# LabJack's example waits 50 ms after changing T8 analog settings (the AIN system restarts).
|
|
319
|
+
if self.spec is T8:
|
|
320
|
+
time.sleep(0.05)
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------ analog inputs
|
|
323
|
+
|
|
324
|
+
def ain_ranges(self, channels: list[int]) -> list[float]:
|
|
325
|
+
"""The ± full-scale range of each channel in volts (T4: fixed 10 V HV / 2.5 V LV)."""
|
|
326
|
+
if self.spec is T4:
|
|
327
|
+
return [10.0 if c < 4 else 2.5 for c in channels]
|
|
328
|
+
values = self.read_names([f"AIN{c}_RANGE" for c in channels])
|
|
329
|
+
default = self.spec.ain_ranges_v[0]
|
|
330
|
+
return [v if v > 0 else default for v in values]
|
|
331
|
+
|
|
332
|
+
def negative_inputs(self, channels: list[int]) -> list[str]:
|
|
333
|
+
"""What each input is measured against: "GND", "AINn" (T7 differential) or, on the T8,
|
|
334
|
+
the channel's own isolated negative terminal."""
|
|
335
|
+
if self.spec is T8:
|
|
336
|
+
return [f"AIN{c}-" for c in channels]
|
|
337
|
+
if not self.spec.selectable_differential:
|
|
338
|
+
return ["GND"] * len(channels)
|
|
339
|
+
negs = self.read_names([f"AIN{c}_NEGATIVE_CH" for c in channels])
|
|
340
|
+
return ["GND" if int(n) == 199 else f"AIN{int(n)}" for n in negs]
|
|
341
|
+
|
|
342
|
+
def read_ain(
|
|
343
|
+
self,
|
|
344
|
+
channels: list[int],
|
|
345
|
+
range_v: float | None = None,
|
|
346
|
+
resolution_index: int | None = None,
|
|
347
|
+
differential: bool | None = None,
|
|
348
|
+
) -> list[float]:
|
|
349
|
+
"""Configure (optionally) and read analog inputs in volts via command-response.
|
|
350
|
+
|
|
351
|
+
``differential`` (T7): True = AINn - AIN(n+1), False = single-ended, None = keep.
|
|
352
|
+
|
|
353
|
+
On the T8, several channels are read *simultaneously*: the first as ``AIN#`` (which
|
|
354
|
+
captures all inputs) and the rest from ``AIN#_CAPTURE`` (datasheet 14.3.2).
|
|
355
|
+
"""
|
|
356
|
+
spec = self.spec
|
|
357
|
+
with self.lock:
|
|
358
|
+
self._check_ain_channels(channels)
|
|
359
|
+
self._check_differential(channels, bool(differential))
|
|
360
|
+
self._check_t4_flexible(channels)
|
|
361
|
+
names: list[str] = []
|
|
362
|
+
values: list[float] = []
|
|
363
|
+
if range_v is not None:
|
|
364
|
+
r = self._match_range(range_v)
|
|
365
|
+
for c in channels:
|
|
366
|
+
names.append(f"AIN{c}_RANGE")
|
|
367
|
+
values.append(r)
|
|
368
|
+
if resolution_index is not None:
|
|
369
|
+
if not 0 <= resolution_index <= spec.max_resolution_index:
|
|
370
|
+
raise InstrumentProtocolError(
|
|
371
|
+
f"Resolution index {resolution_index} is out of range for the {spec.model} "
|
|
372
|
+
f"(0-{spec.max_resolution_index}; 0 = device default)."
|
|
373
|
+
)
|
|
374
|
+
if spec is T8: # one shared resolution index for all T8 inputs
|
|
375
|
+
names.append("AIN_ALL_RESOLUTION_INDEX")
|
|
376
|
+
values.append(resolution_index)
|
|
377
|
+
else:
|
|
378
|
+
for c in channels:
|
|
379
|
+
names.append(f"AIN{c}_RESOLUTION_INDEX")
|
|
380
|
+
values.append(resolution_index)
|
|
381
|
+
if spec.selectable_differential and differential is not None:
|
|
382
|
+
for c in channels:
|
|
383
|
+
names.append(f"AIN{c}_NEGATIVE_CH")
|
|
384
|
+
values.append(c + 1 if differential else 199) # 199 = single-ended
|
|
385
|
+
if names:
|
|
386
|
+
self.write_names(names, values)
|
|
387
|
+
if range_v is not None or resolution_index is not None:
|
|
388
|
+
self._t8_settle()
|
|
389
|
+
if spec is T8 and len(channels) > 1:
|
|
390
|
+
read = [f"AIN{channels[0]}"] + [f"AIN{c}_CAPTURE" for c in channels[1:]]
|
|
391
|
+
else:
|
|
392
|
+
read = [f"AIN{c}" for c in channels]
|
|
393
|
+
return self.read_names(read)
|
|
394
|
+
|
|
395
|
+
# ------------------------------------------------------------ digital I/O
|
|
396
|
+
|
|
397
|
+
def read_dio(self, lines: list[int] | None = None) -> list[dict[str, Any]]:
|
|
398
|
+
"""State and direction of digital lines from the DIO_STATE / DIO_DIRECTION bitmasks.
|
|
399
|
+
|
|
400
|
+
Unlike reading a single-line register (FIO3, ...), which switches that line to input,
|
|
401
|
+
these bitmask reads do not change any line's direction (Modbus map, DIO_STATE).
|
|
402
|
+
"""
|
|
403
|
+
lines = list(self.spec.dio_lines) if lines is None else lines
|
|
404
|
+
for line in lines:
|
|
405
|
+
self._check_line(line)
|
|
406
|
+
names = ["DIO_STATE", "DIO_DIRECTION"] + (["DIO_ANALOG_ENABLE"] if self.spec is T4 else [])
|
|
407
|
+
raw = [int(v) for v in self.read_names(names)]
|
|
408
|
+
state, direction = raw[0], raw[1]
|
|
409
|
+
analog = raw[2] if self.spec is T4 else 0
|
|
410
|
+
out = []
|
|
411
|
+
for line in lines:
|
|
412
|
+
bit = 1 << line
|
|
413
|
+
if analog & bit:
|
|
414
|
+
mode, level = "analog", None
|
|
415
|
+
else:
|
|
416
|
+
mode = "output" if direction & bit else "input"
|
|
417
|
+
level = bool(state & bit)
|
|
418
|
+
out.append({"line": line, "name": dio_name(line), "direction": mode, "high": level})
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
def set_dio(self, line: int, high: bool) -> None:
|
|
422
|
+
"""Drive one line high (3.3 V) or low. Writing a single-line register makes it an output."""
|
|
423
|
+
self._check_line(line)
|
|
424
|
+
with self.lock:
|
|
425
|
+
self.write_names([dio_name(line)], [1 if high else 0])
|
|
426
|
+
self.driven_lines.add(line)
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------ DAC
|
|
429
|
+
|
|
430
|
+
def write_dac(self, dac: int, voltage_v: float) -> float:
|
|
431
|
+
"""Set DAC0/DAC1 and return the read-back value (the value last written to the DAC chip)."""
|
|
432
|
+
if dac not in (0, 1):
|
|
433
|
+
raise InstrumentProtocolError("T-series devices have DAC0 and DAC1 only.")
|
|
434
|
+
if not 0.0 <= voltage_v <= self.spec.dac_max_v:
|
|
435
|
+
raise InstrumentProtocolError(
|
|
436
|
+
f"{voltage_v:g} V is outside the {self.spec.model} DAC range of 0-{self.spec.dac_max_v:g} V."
|
|
437
|
+
)
|
|
438
|
+
with self.lock:
|
|
439
|
+
self.write_names([f"DAC{dac}"], [voltage_v])
|
|
440
|
+
return self.read_names([f"DAC{dac}"])[0]
|
|
441
|
+
|
|
442
|
+
def read_dacs(self) -> list[float]:
|
|
443
|
+
return self.read_names(["DAC0", "DAC1"])
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------ temperatures
|
|
446
|
+
|
|
447
|
+
def device_temperature_k(self) -> tuple[float, float, list[float] | None]:
|
|
448
|
+
"""(TEMPERATURE_DEVICE_K, TEMPERATURE_AIR_K, T8 per-terminal sensors or None)."""
|
|
449
|
+
device_k, air_k = self.read_names(["TEMPERATURE_DEVICE_K", "TEMPERATURE_AIR_K"])
|
|
450
|
+
terminals = None
|
|
451
|
+
if self.spec is T8:
|
|
452
|
+
names = ["TEMPERATURE0"] + [f"TEMPERATURE{n}_CAPTURE" for n in range(1, 8)]
|
|
453
|
+
terminals = self.read_names(names)
|
|
454
|
+
return device_k, air_k, terminals
|
|
455
|
+
|
|
456
|
+
def read_thermocouple(
|
|
457
|
+
self,
|
|
458
|
+
channel: int,
|
|
459
|
+
tc_type: str,
|
|
460
|
+
cjc: Literal["internal", "lm34"] = "internal",
|
|
461
|
+
cjc_channel: int | None = None,
|
|
462
|
+
differential: bool = False,
|
|
463
|
+
resolution_index: int | None = None,
|
|
464
|
+
) -> ThermocoupleResult:
|
|
465
|
+
"""Configure the AIN thermocouple extended feature on ``channel`` and read it (°C)."""
|
|
466
|
+
spec = self.spec
|
|
467
|
+
if not spec.thermocouple:
|
|
468
|
+
raise InstrumentProtocolError(
|
|
469
|
+
"The T4 has no thermocouple extended feature (T7/T8 only). Use an amplifier such as "
|
|
470
|
+
"the LJTick-InAmp with read_analog_inputs instead."
|
|
471
|
+
)
|
|
472
|
+
tc = tc_type.upper()
|
|
473
|
+
if tc not in THERMOCOUPLE_EF_INDEX:
|
|
474
|
+
raise InstrumentProtocolError(f"Unknown thermocouple type {tc_type!r}; use one of {', '.join(THERMOCOUPLE_EF_INDEX)}.")
|
|
475
|
+
with self.lock:
|
|
476
|
+
self._check_ain_channels([channel])
|
|
477
|
+
self._check_differential([channel], differential)
|
|
478
|
+
if cjc == "internal":
|
|
479
|
+
if spec is T8:
|
|
480
|
+
cjc_address = T8_TEMPERATURE_CAPTURE_ADDRESS + 2 * channel
|
|
481
|
+
else:
|
|
482
|
+
cjc_address = TEMPERATURE_DEVICE_K_ADDRESS
|
|
483
|
+
slope, offset = 1.0, 0.0
|
|
484
|
+
else:
|
|
485
|
+
if cjc_channel is None:
|
|
486
|
+
raise InstrumentProtocolError("cjc='lm34' needs cjc_channel (the AIN the LM34 is wired to).")
|
|
487
|
+
self._check_ain_channels([cjc_channel])
|
|
488
|
+
if cjc_channel in (channel, channel + 1 if differential else channel):
|
|
489
|
+
raise InstrumentProtocolError("The LM34 must be on a different AIN than the thermocouple.")
|
|
490
|
+
cjc_address = 2 * cjc_channel # AIN n lives at Modbus address 2n
|
|
491
|
+
slope, offset = LM34_SLOPE_K_PER_V, LM34_OFFSET_K
|
|
492
|
+
p = f"AIN{channel}_EF_"
|
|
493
|
+
names = [p + "INDEX", p + "CONFIG_A", p + "CONFIG_B", p + "CONFIG_D", p + "CONFIG_E"]
|
|
494
|
+
values: list[float] = [THERMOCOUPLE_EF_INDEX[tc], 1, cjc_address, slope, offset] # CONFIG_A 1 = °C
|
|
495
|
+
if spec.selectable_differential:
|
|
496
|
+
names.append(f"AIN{channel}_NEGATIVE_CH")
|
|
497
|
+
values.append(channel + 1 if differential else 199)
|
|
498
|
+
if resolution_index is not None:
|
|
499
|
+
if not 0 <= resolution_index <= spec.max_resolution_index:
|
|
500
|
+
raise InstrumentProtocolError(
|
|
501
|
+
f"Resolution index {resolution_index} is out of range for the {spec.model} "
|
|
502
|
+
f"(0-{spec.max_resolution_index})."
|
|
503
|
+
)
|
|
504
|
+
names.append("AIN_ALL_RESOLUTION_INDEX" if spec is T8 else f"AIN{channel}_RESOLUTION_INDEX")
|
|
505
|
+
values.append(resolution_index)
|
|
506
|
+
self.write_names(names, values)
|
|
507
|
+
self._t8_settle()
|
|
508
|
+
# Only READ_A triggers a new measurement; B and C return values from that measurement.
|
|
509
|
+
temp, volts, cjc_c = self.read_names([p + "READ_A", p + "READ_B", p + "READ_C"])
|
|
510
|
+
return ThermocoupleResult(None if temp == DUMMY_VALUE else temp, volts, cjc_c)
|
|
511
|
+
|
|
512
|
+
# ------------------------------------------------------------ stream
|
|
513
|
+
|
|
514
|
+
def stream_ain(
|
|
515
|
+
self,
|
|
516
|
+
channels: list[int],
|
|
517
|
+
scan_rate_hz: float,
|
|
518
|
+
num_scans: int,
|
|
519
|
+
range_v: float | None = None,
|
|
520
|
+
resolution_index: int | None = None,
|
|
521
|
+
) -> StreamData:
|
|
522
|
+
"""Hardware-timed acquisition of ``num_scans`` scans with the LJM stream functions.
|
|
523
|
+
|
|
524
|
+
Configuration follows LabJack's ``stream_basic.py`` example for each device type.
|
|
525
|
+
The stream is always stopped, even if reading fails.
|
|
526
|
+
"""
|
|
527
|
+
spec = self.spec
|
|
528
|
+
n = len(channels)
|
|
529
|
+
self._check_ain_channels(channels)
|
|
530
|
+
if spec is T8:
|
|
531
|
+
if channels != list(range(channels[0], channels[0] + n)):
|
|
532
|
+
raise InstrumentProtocolError(
|
|
533
|
+
"On the T8 all streamed AINs must be adjacent and in ascending order (e.g. [0, 1, 2])."
|
|
534
|
+
)
|
|
535
|
+
if scan_rate_hz > spec.max_stream_scan_rate_hz:
|
|
536
|
+
raise InstrumentProtocolError(f"The T8 streams at most {spec.max_stream_scan_rate_hz:g} scans/s.")
|
|
537
|
+
elif scan_rate_hz * n > spec.max_stream_samples_per_s:
|
|
538
|
+
raise InstrumentProtocolError(
|
|
539
|
+
f"{n} channels x {scan_rate_hz:g} Hz = {scan_rate_hz * n:g} samples/s exceeds the "
|
|
540
|
+
f"{spec.model} stream maximum of {spec.max_stream_samples_per_s:g} samples/s."
|
|
541
|
+
)
|
|
542
|
+
if resolution_index is not None and not 0 <= resolution_index <= spec.max_stream_resolution_index:
|
|
543
|
+
raise InstrumentProtocolError(
|
|
544
|
+
f"Stream resolution index must be 0-{spec.max_stream_resolution_index} on the {spec.model}"
|
|
545
|
+
+ (" (the T7-Pro 24-bit ADC, indices 9-12, cannot stream)." if spec is T7_PRO else ".")
|
|
546
|
+
)
|
|
547
|
+
if num_scans * n > MAX_STREAM_SAMPLES:
|
|
548
|
+
raise InstrumentProtocolError(
|
|
549
|
+
f"{num_scans} scans x {n} channels is more than the {MAX_STREAM_SAMPLES} samples this "
|
|
550
|
+
"server buffers. Shorten the duration or lower the scan rate."
|
|
551
|
+
)
|
|
552
|
+
res = 0 if resolution_index is None else resolution_index
|
|
553
|
+
r = None if range_v is None else self._match_range(range_v) # T4: explains fixed ranges
|
|
554
|
+
with self.lock:
|
|
555
|
+
self._check_t4_flexible(channels)
|
|
556
|
+
if spec is T4:
|
|
557
|
+
names, values = ["STREAM_SETTLING_US", "STREAM_RESOLUTION_INDEX"], [0.0, float(res)]
|
|
558
|
+
else:
|
|
559
|
+
# Ensure triggered stream is off and the internal clock is used.
|
|
560
|
+
self.write_names(["STREAM_TRIGGER_INDEX", "STREAM_CLOCK_SOURCE"], [0, 0])
|
|
561
|
+
names, values = ["STREAM_RESOLUTION_INDEX"], [float(res)]
|
|
562
|
+
if r is not None:
|
|
563
|
+
names += [f"AIN{c}_RANGE" for c in channels]
|
|
564
|
+
values += [r] * n
|
|
565
|
+
if spec.selectable_differential: # T7: single-ended, auto settling
|
|
566
|
+
names += [f"AIN{c}_NEGATIVE_CH" for c in channels] + ["STREAM_SETTLING_US"]
|
|
567
|
+
values += [199.0] * n + [0.0]
|
|
568
|
+
self.write_names(names, values)
|
|
569
|
+
self._t8_settle()
|
|
570
|
+
scan_names = [f"AIN{c}" for c in channels]
|
|
571
|
+
addresses = self._call("namesToAddresses", self.ljm.namesToAddresses, n, scan_names)[0]
|
|
572
|
+
scans_per_read = max(1, min(num_scans, int(scan_rate_hz / 4)))
|
|
573
|
+
self._log(f"eStreamStart {scan_names} at {scan_rate_hz:g} Hz, {num_scans} scans")
|
|
574
|
+
actual = self._call(
|
|
575
|
+
"eStreamStart", self.ljm.eStreamStart, self.handle, scans_per_read, n, addresses, scan_rate_hz
|
|
576
|
+
)
|
|
577
|
+
raw: list[float] = []
|
|
578
|
+
max_dev = max_ljm = 0
|
|
579
|
+
try:
|
|
580
|
+
while len(raw) < num_scans * n:
|
|
581
|
+
data, dev_backlog, ljm_backlog = self._call("eStreamRead", self.ljm.eStreamRead, self.handle)
|
|
582
|
+
raw.extend(data)
|
|
583
|
+
max_dev, max_ljm = max(max_dev, dev_backlog), max(max_ljm, ljm_backlog)
|
|
584
|
+
finally:
|
|
585
|
+
self._stop_stream()
|
|
586
|
+
raw = raw[: num_scans * n]
|
|
587
|
+
skipped = sum(1 for v in raw[::n] if v == DUMMY_VALUE)
|
|
588
|
+
per_channel = [[math.nan if v == DUMMY_VALUE else v for v in raw[i::n]] for i in range(n)]
|
|
589
|
+
self._log(f"stream done: {num_scans} scans at {actual:g} Hz, {skipped} skipped")
|
|
590
|
+
return StreamData(channels, float(actual), per_channel, skipped, max_dev, max_ljm)
|
|
591
|
+
|
|
592
|
+
def _stop_stream(self) -> None:
|
|
593
|
+
try:
|
|
594
|
+
self.ljm.eStreamStop(self.handle)
|
|
595
|
+
self._log("eStreamStop")
|
|
596
|
+
except self.ljm.LJMError as exc: # not running / already stopped
|
|
597
|
+
self._log(f"eStreamStop: {exc}")
|
|
598
|
+
|
|
599
|
+
# ------------------------------------------------------------ safety
|
|
600
|
+
|
|
601
|
+
def safe_state(self, dio_mode: Literal["input", "low"] = "input") -> list[str]:
|
|
602
|
+
"""Stop any stream, set both DACs to 0 V and release (input) or drive low every DIO line
|
|
603
|
+
this server drove plus the configured ``safe_dio`` lines. Tries every step."""
|
|
604
|
+
done: list[str] = []
|
|
605
|
+
errors: list[str] = []
|
|
606
|
+
with self.lock:
|
|
607
|
+
self._stop_stream()
|
|
608
|
+
try:
|
|
609
|
+
self.write_names(["DAC0", "DAC1"], [0.0, 0.0])
|
|
610
|
+
done.append("DAC0 and DAC1 set to 0 V")
|
|
611
|
+
except InstrumentError as exc:
|
|
612
|
+
errors.append(str(exc))
|
|
613
|
+
for line in sorted(self.driven_lines | self.safe_lines):
|
|
614
|
+
name = dio_name(line)
|
|
615
|
+
try:
|
|
616
|
+
if dio_mode == "input":
|
|
617
|
+
self.read_names([name]) # a single-line read switches the line to input
|
|
618
|
+
self.driven_lines.discard(line)
|
|
619
|
+
done.append(f"{name} set to input")
|
|
620
|
+
else:
|
|
621
|
+
self.write_names([name], [0])
|
|
622
|
+
done.append(f"{name} driven low")
|
|
623
|
+
except InstrumentError as exc:
|
|
624
|
+
errors.append(str(exc))
|
|
625
|
+
if errors:
|
|
626
|
+
raise InstrumentProtocolError(
|
|
627
|
+
"Safe state only partly applied. Done: " + ("; ".join(done) or "nothing") + ". Failed: " + "; ".join(errors)
|
|
628
|
+
)
|
|
629
|
+
return done
|
|
630
|
+
|
|
631
|
+
def close(self) -> None:
|
|
632
|
+
with contextlib.suppress(Exception):
|
|
633
|
+
self.ljm.close(self.handle)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def open_device(
|
|
637
|
+
ljm: Any,
|
|
638
|
+
device_type: str = "ANY",
|
|
639
|
+
connection_type: str = "ANY",
|
|
640
|
+
identifier: str = "ANY",
|
|
641
|
+
audit: AuditLog | None = None,
|
|
642
|
+
) -> LabJackT:
|
|
643
|
+
"""``ljm.openS(device_type, connection_type, identifier)`` and wrap the handle."""
|
|
644
|
+
try:
|
|
645
|
+
handle = ljm.openS(device_type, connection_type, identifier)
|
|
646
|
+
except ljm.LJMError as exc:
|
|
647
|
+
code = getattr(exc, "errorCode", None)
|
|
648
|
+
hint = _HINTS.get(code or 0, "")
|
|
649
|
+
raise InstrumentConnectionError(
|
|
650
|
+
f"Could not open a LabJack (device_type={device_type}, connection_type={connection_type}, "
|
|
651
|
+
f"identifier={identifier}): LJM error {code} {getattr(exc, 'errorString', exc)}"
|
|
652
|
+
+ (f" - {hint}." if hint else ".")
|
|
653
|
+
) from exc
|
|
654
|
+
if audit is not None:
|
|
655
|
+
audit.event(f"openS({device_type}, {connection_type}, {identifier}) -> handle {handle}", "ljm")
|
|
656
|
+
return LabJackT(ljm, handle, audit)
|