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.
@@ -0,0 +1 @@
1
+ """LabMCP server for LabJack T-series DAQ devices (T4, T7/T7-Pro, T8) via the LJM library."""
@@ -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)