et43 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.
et43/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Human-friendly Python client for EastTester ET43x LCR meters (ET430-ET433)."""
2
+
3
+ from .client import ET43, Idn, Reading
4
+ from .enums import (
5
+ DisplayPage,
6
+ Equivalent,
7
+ PrimaryMode,
8
+ SecondaryMode,
9
+ Speed,
10
+ )
11
+
12
+ __all__ = [
13
+ "ET43",
14
+ "DisplayPage",
15
+ "Equivalent",
16
+ "Idn",
17
+ "PrimaryMode",
18
+ "Reading",
19
+ "SecondaryMode",
20
+ "Speed",
21
+ ]
et43/client.py ADDED
@@ -0,0 +1,440 @@
1
+ """Minimal SCPI client for EastTester ET43x LCR meters (incl. ET433).
2
+
3
+ The ET43x series exposes a virtual serial port (USB CDC) and speaks a small
4
+ SCPI dialect. This module exposes the device commands as named properties and
5
+ returns raw measurement values; higher-level helpers live in ``scripts/``.
6
+ """
7
+
8
+ import contextlib
9
+ import os
10
+ import time
11
+ from collections.abc import Generator
12
+ from dataclasses import dataclass
13
+ from typing import NamedTuple, Self, override
14
+
15
+ import serial
16
+ from serial.serialutil import Timeout
17
+ from serial.tools import list_ports
18
+
19
+ from .enums import (
20
+ DisplayPage,
21
+ Equivalent,
22
+ PrimaryMode,
23
+ SecondaryMode,
24
+ Speed,
25
+ )
26
+
27
+ CALIBRATION_TIMEOUT = 45.0
28
+ """Default maximum wait for the ``CORR:EXEC`` acknowledgement, in seconds."""
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Idn:
33
+ """Contents of ``*IDN?``."""
34
+
35
+ manufacturer: str
36
+ model: str
37
+ firmware: str
38
+ hardware: str
39
+ serial: str
40
+ raw: str
41
+
42
+ @override
43
+ def __str__(self) -> str:
44
+ return (
45
+ f"{self.manufacturer} {self.model} (firmware {self.firmware}, hardware {self.hardware}, S/N {self.serial})"
46
+ )
47
+
48
+
49
+ class Reading(NamedTuple):
50
+ """One ``FETCH?`` result.
51
+
52
+ A tuple, so ``primary, secondary = lcr.fetch()`` works. ``secondary`` is
53
+ ``None`` when the instrument returns a single value (e.g. in DCR mode).
54
+ """
55
+
56
+ primary: float
57
+ secondary: float | None
58
+
59
+
60
+ class ET43:
61
+ """Client for an EastTester ET43x LCR meter.
62
+
63
+ Parameters
64
+ ----------
65
+ port:
66
+ A ready-to-use, already open ``serial.Serial`` instance. The client
67
+ takes ownership: ``close()`` (also called by the context manager)
68
+ always closes it.
69
+
70
+ The constructor performs no I/O: it only takes ownership of the port.
71
+ Use :meth:`open` for discovery and port creation, then
72
+ :meth:`auto_remote` for a block that enters remote mode and always returns
73
+ the instrument to local control.
74
+
75
+ Notes
76
+ -----
77
+ The client is not thread-safe. One instance is meant to be used by one
78
+ thread at a time; serialize access externally if needed.
79
+ """
80
+
81
+ def __init__(self, port: serial.Serial) -> None:
82
+ self.port = port
83
+ # A single byte read after a CR when it was not followed by LF.
84
+ # It belongs to the next response and must be consumed by the next
85
+ # ``_read()`` call. One byte is enough because ``_read()`` consumes
86
+ # the stream one character at a time.
87
+ self._pending_byte: bytes | None = None
88
+
89
+ # ------------------------------------------------------------------
90
+ # Connection handling
91
+
92
+ @classmethod
93
+ def open(
94
+ cls,
95
+ port: str | None = None,
96
+ baudrate: int = 115_200,
97
+ *,
98
+ timeout: float = 2.0,
99
+ exclusive: bool = True,
100
+ ) -> ET43:
101
+ """Open a serial port and return a connected client.
102
+
103
+ When ``port`` is omitted, :meth:`discover` is used. The returned client
104
+ owns the port and closes it in ``close()``.
105
+ """
106
+ if port is None:
107
+ port, baudrate = cls.discover(baudrates=(baudrate, 9_600))
108
+
109
+ link = serial.Serial(
110
+ port=port,
111
+ baudrate=baudrate,
112
+ timeout=timeout,
113
+ write_timeout=timeout,
114
+ exclusive=exclusive and os.name == "posix",
115
+ )
116
+
117
+ return cls(link)
118
+
119
+ @classmethod
120
+ def discover(
121
+ cls,
122
+ ports: list[str] | None = None,
123
+ baudrates: tuple[int, ...] = (115_200, 9_600),
124
+ timeout: float = 0.8,
125
+ ) -> tuple[str, int]:
126
+ """Find a connected ET43x and return ``(port, baudrate)``.
127
+
128
+ Only USB serial adapters are probed by default (the ET433 uses a
129
+ CH340). Pass explicit ``ports`` to search something else.
130
+ """
131
+ if ports is None:
132
+ candidates = list(list_ports.comports())
133
+ selected: list[str] = [
134
+ item.device
135
+ for item in candidates
136
+ if item.vid == 0x1A86
137
+ or "CH340" in (item.description or "").upper()
138
+ or "USB SERIAL" in (item.description or "").upper()
139
+ ]
140
+ ports = selected if selected else [item.device for item in candidates]
141
+
142
+ if not ports:
143
+ raise ConnectionError("no serial ports found")
144
+
145
+ for device in ports:
146
+ for baudrate in baudrates:
147
+ try:
148
+ options: dict[str, object] = {
149
+ "port": device,
150
+ "baudrate": baudrate,
151
+ "timeout": timeout,
152
+ }
153
+ if os.name == "posix":
154
+ options["exclusive"] = True
155
+ with serial.Serial(**options) as link: # type: ignore[arg-type]
156
+ time.sleep(0.1)
157
+ link.reset_input_buffer()
158
+ link.write(b"*IDN?\n")
159
+ link.flush()
160
+ time.sleep(0.15)
161
+ answer = link.read(512)
162
+ except serial.SerialException, OSError:
163
+ continue
164
+ if b"ET43" in answer.upper():
165
+ return device, baudrate
166
+
167
+ raise ConnectionError(
168
+ "no ET43x device answered; checked " + ", ".join(f"{port} @ {baud}" for port in ports for baud in baudrates)
169
+ )
170
+
171
+ def close(self) -> None:
172
+ """Close the serial port."""
173
+ self.port.close()
174
+
175
+ @property
176
+ def is_open(self) -> bool:
177
+ """Whether the serial port is currently open."""
178
+ return bool(self.port.is_open)
179
+
180
+ def __enter__(self) -> Self:
181
+ return self
182
+
183
+ def __exit__(self, *_) -> None:
184
+ self.close()
185
+
186
+ # ------------------------------------------------------------------
187
+ # Low-level serial / SCPI plumbing
188
+
189
+ def _write(self, command: str) -> None:
190
+ """Write a command without reading a response."""
191
+ self._pending_byte = None
192
+ self.port.reset_input_buffer()
193
+ self.port.write((command + "\n").encode("ascii"))
194
+ self.port.flush()
195
+
196
+ def _read(self, timeout: float | None = None) -> str:
197
+ """Read one response; raise on protocol errors or missing data.
198
+
199
+ ``timeout=None`` uses the port's configured timeout. Only calibration
200
+ passes an explicit value, because ``CORR:EXEC`` takes tens of seconds.
201
+ """
202
+ port_timeout = self.port.timeout
203
+ if timeout is not None:
204
+ self.port.timeout = timeout
205
+ deadline = Timeout(self.port.timeout)
206
+ buffer = bytearray()
207
+
208
+ try:
209
+ while True:
210
+ if self._pending_byte is not None:
211
+ char = self._pending_byte
212
+ self._pending_byte = None
213
+ else:
214
+ char = self.port.read(1)
215
+
216
+ if not char:
217
+ break
218
+ if char in (b"\r", b"\n"):
219
+ if buffer:
220
+ # A normal device sends CRLF; consume the LF part
221
+ # so the next read does not see it as an empty line.
222
+ if char == b"\r" and self.port.in_waiting:
223
+ extra = self.port.read(1)
224
+ if extra and extra != b"\n":
225
+ self._pending_byte = extra
226
+ break
227
+ # Ignore leading / duplicated terminators.
228
+ continue
229
+ buffer.extend(char)
230
+ if deadline.expired():
231
+ break
232
+ finally:
233
+ if timeout is not None:
234
+ self.port.timeout = port_timeout
235
+
236
+ response = buffer.decode("ascii", errors="replace").strip()
237
+ # ET433 answers "cmd err" / "exec err"; ET44.py also expects
238
+ # "execu err" from the ET44/ET45, so match any "... err" reply.
239
+ if response.endswith(" err"):
240
+ raise RuntimeError(response)
241
+ if not response:
242
+ raise TimeoutError("no response (timeout)")
243
+ return response
244
+
245
+ def _request(self, command: str, *, timeout: float | None = None) -> str:
246
+ """Write a command and read its response.
247
+
248
+ ``timeout`` overrides the port timeout for this response only.
249
+ """
250
+ self._write(command)
251
+ return self._read(timeout)
252
+
253
+ def _command(self, command: str, *, timeout: float | None = None) -> None:
254
+ """Write a command and require an ``exec success`` acknowledgement.
255
+
256
+ ``timeout`` overrides the port timeout for this response only.
257
+ """
258
+ response = self._request(command, timeout=timeout)
259
+ if response != "exec success":
260
+ raise RuntimeError(f"unexpected response to {command!r}: {response!r}")
261
+
262
+ # ------------------------------------------------------------------
263
+ # Identification and display
264
+
265
+ @property
266
+ def idn(self) -> Idn:
267
+ """Query ``*IDN?`` and return the instrument identification."""
268
+ raw = self._request("*IDN?")
269
+ fields = [item.strip() for item in raw.split(",")]
270
+ if len(fields) != 5:
271
+ raise ValueError(f"cannot parse *IDN? response: {raw!r}")
272
+ return Idn(*fields, raw=raw)
273
+
274
+ @property
275
+ def display_page(self) -> DisplayPage:
276
+ """Current display page: ``MEAS`` or ``SYST``."""
277
+ return DisplayPage.from_string(self._request("DISP:PAGE?"))
278
+
279
+ @display_page.setter
280
+ def display_page(self, page: DisplayPage) -> None:
281
+ self._command(f"DISP:PAGE {page.value}")
282
+
283
+ # ------------------------------------------------------------------
284
+ # Measurement configuration
285
+
286
+ @property
287
+ def mode(self) -> PrimaryMode:
288
+ """Primary parameter: ``AUTO``, ``R``, ``C``, ``L``, ``Z``, ``DCR``, ``ECAP``."""
289
+ return PrimaryMode.from_string(self._request("FUNC:IMP:A?"))
290
+
291
+ @mode.setter
292
+ def mode(self, mode: PrimaryMode) -> None:
293
+ self._command(f"FUNC:IMP:A {mode.value}")
294
+
295
+ @property
296
+ def secondary(self) -> SecondaryMode:
297
+ """Secondary parameter: ``X``, ``D``, ``Q``, ``THR`` (phase) or ``ESR``."""
298
+ return SecondaryMode.from_string(self._request("FUNC:IMP:B?"))
299
+
300
+ @secondary.setter
301
+ def secondary(self, mode: SecondaryMode) -> None:
302
+ self._command(f"FUNC:IMP:B {mode.value}")
303
+
304
+ @property
305
+ def equivalent(self) -> Equivalent:
306
+ """Equivalent circuit: ``SER`` (series) or ``PAR`` (parallel)."""
307
+ return Equivalent.from_string(self._request("FUNC:IMP:EQU?"))
308
+
309
+ @equivalent.setter
310
+ def equivalent(self, eq: Equivalent) -> None:
311
+ self._command(f"FUNC:IMP:EQU {eq.value}")
312
+
313
+ @property
314
+ def frequency(self) -> float:
315
+ """Test frequency in Hz."""
316
+ return float(self._request("FREQ?"))
317
+
318
+ @frequency.setter
319
+ def frequency(self, value: float) -> None:
320
+ self._command(f"FREQ {value:g}")
321
+
322
+ @property
323
+ def level_mv(self) -> float:
324
+ """Test signal level in mV."""
325
+ return float(self._request("VOLT?"))
326
+
327
+ @level_mv.setter
328
+ def level_mv(self, value: float) -> None:
329
+ self._command(f"VOLT {value:g}")
330
+
331
+ @property
332
+ def bias_mv(self) -> float:
333
+ """Internal DC bias in mV."""
334
+ return float(self._request("BIAS:VOLT?"))
335
+
336
+ @bias_mv.setter
337
+ def bias_mv(self, value: float) -> None:
338
+ self._command(f"BIAS:VOLT {value:g}")
339
+
340
+ @property
341
+ def speed(self) -> Speed:
342
+ """Measurement speed: ``FAST``, ``MED`` or ``SLOW``."""
343
+ return Speed.from_string(self._request("APER?"))
344
+
345
+ @speed.setter
346
+ def speed(self, speed: Speed) -> None:
347
+ self._command(f"APER {speed.value}")
348
+
349
+ @property
350
+ def measurement_range(self) -> int | None:
351
+ """Current impedance range in ohms, or ``None`` for automatic range."""
352
+ answer = self._request("FUNC:IMP:RANGE?")
353
+ return None if answer == "AUTO" else int(answer)
354
+
355
+ @measurement_range.setter
356
+ def measurement_range(self, value: int | None) -> None:
357
+ command = "AUTO" if value is None else str(value)
358
+ self._command(f"FUNC:IMP:RANGE {command}")
359
+
360
+ @property
361
+ def relative_mode(self) -> bool:
362
+ """Whether relative (deviation) measurement mode is enabled."""
363
+ return self._request("FUNC:DEV:MODE?") == "ON"
364
+
365
+ @relative_mode.setter
366
+ def relative_mode(self, value: bool) -> None:
367
+ self._command(f"FUNC:DEV:MODE {'ON' if value else 'OFF'}")
368
+
369
+ # ------------------------------------------------------------------
370
+ # Measurements and control
371
+
372
+ def fetch(self) -> Reading:
373
+ """Read one ``FETCH?`` response as :class:`Reading`.
374
+
375
+ The instrument returns either one value (e.g. in DCR mode) or two.
376
+ Modes and units are not part of the response, so no extra queries are
377
+ made. ``secondary`` is ``None`` when the device returns one value.
378
+ """
379
+ answer = self._request("FETCH?")
380
+ # Verified on ET433 V2.00.2414.001: values are comma-separated
381
+ # ("2.820165e+04,6.536892e+02").
382
+ match [float(value) for value in answer.split(",")]:
383
+ case [primary]:
384
+ return Reading(primary, None)
385
+ case [primary, secondary]:
386
+ return Reading(primary, secondary)
387
+ raise ValueError(f"cannot parse FETCH? response: {answer!r}")
388
+
389
+ def calibrate(self, *, timeout: float = CALIBRATION_TIMEOUT) -> None:
390
+ """Run open- or short-circuit correction (depends on the fixture).
391
+
392
+ The instrument decides whether it sees an open or a short, so connect
393
+ the appropriate fixture first. Repeat once for open and once for short.
394
+ **This overwrites the open/short correction data.**
395
+
396
+ The command takes tens of seconds and answers ``exec success`` when
397
+ done. The method waits up to ``timeout`` seconds (default
398
+ :data:`CALIBRATION_TIMEOUT`, 45 s) for that acknowledgement.
399
+ """
400
+ self._command("CORR:EXEC", timeout=timeout)
401
+
402
+ def remote(self) -> None:
403
+ """Enter remote mode (front panel locked)."""
404
+ self._command("SYST:REMOTE")
405
+
406
+ def local(self) -> None:
407
+ """Return the instrument to local (front-panel) control."""
408
+ # SYST:LOCAL is not acknowledged by the firmware.
409
+ self._write("SYST:LOCAL")
410
+
411
+ @contextlib.contextmanager
412
+ def auto_remote(self) -> Generator[Self]:
413
+ """Use remote mode for the duration of the block and close the port.
414
+
415
+ Sends ``SYST:REMOTE`` on entry and ``SYST:LOCAL`` on exit, including
416
+ when the block raises, then closes the serial port. This keeps the
417
+ front panel unlocked after the session::
418
+
419
+ with ET43.open().auto_remote() as lcr:
420
+ ...
421
+
422
+ ``SYST:LOCAL`` is also sent when ``SYST:REMOTE`` itself fails: the
423
+ instrument may have applied it and only the acknowledgement was lost.
424
+ When the block (or entering remote) raises, a failure of
425
+ ``SYST:LOCAL`` is suppressed so the original error propagates.
426
+
427
+ The port is closed on exit, so call this right on :meth:`open` rather
428
+ than on a client you intend to keep using afterwards.
429
+ """
430
+ with self:
431
+ try:
432
+ self.remote()
433
+ yield self
434
+ except BaseException:
435
+ # Best effort: do not mask the original error.
436
+ with contextlib.suppress(Exception):
437
+ self.local()
438
+ raise
439
+ else:
440
+ self.local()
et43/enums.py ADDED
@@ -0,0 +1,83 @@
1
+ """Strongly typed instrument settings."""
2
+ from enum import StrEnum
3
+ from typing import Self
4
+
5
+
6
+ class ParsedUnit(StrEnum):
7
+ """Base for SCPI enums that can be parsed from short or long tokens."""
8
+
9
+ @classmethod
10
+ def _matches(cls, text: str, member: Self) -> bool:
11
+ """Whether *text* names *member*; subclasses may tighten this."""
12
+ return text == member.name or text.startswith(member.value)
13
+
14
+ @classmethod
15
+ def from_string(cls, value: str | int | Self) -> Self:
16
+ """Match a short, long, or exact SCPI token to a member."""
17
+ if isinstance(value, cls):
18
+ return value
19
+
20
+ text = str(value).strip().upper()
21
+ for member in cls:
22
+ if cls._matches(text, member):
23
+ return member
24
+
25
+ choices = ", ".join(member.value for member in cls)
26
+ raise ValueError(f"{cls.__name__} must be one of {choices}")
27
+
28
+
29
+ class SecondaryMode(ParsedUnit):
30
+ """Secondary measurement parameter."""
31
+
32
+ X = "X"
33
+ D = "D"
34
+ Q = "Q"
35
+ THR = "THR"
36
+ ESR = "ESR"
37
+
38
+
39
+ class PrimaryMode(ParsedUnit):
40
+ """Primary measurement parameter."""
41
+
42
+ AUTO = "AUTO"
43
+ R = "R"
44
+ C = "C"
45
+ L = "L"
46
+ Z = "Z"
47
+ DCR = "DCR"
48
+ ECAP = "ECAP"
49
+
50
+
51
+ class Speed(ParsedUnit):
52
+ """Measurement speed."""
53
+
54
+ FAST = "FAST"
55
+ MED = "MED"
56
+ SLOW = "SLOW"
57
+
58
+
59
+ class Equivalent(ParsedUnit):
60
+ """Series or parallel equivalent circuit.
61
+
62
+ EastTester firmware (ET43x, ET44x/ET45x) spells parallel as ``PALlel``:
63
+ it rejects ``PAR`` and reports ``PALlel``. The member is named ``PAR``,
64
+ but its value is the token the instrument actually accepts.
65
+ """
66
+
67
+ SER = "SER"
68
+ # "PAL" is not a typo here — it is EastTester's typo. Their SCPI manual
69
+ # (ET44/ET45, shared firmware lineage with ET43x) defines the keyword as
70
+ # "PALlel" (sic, a misspelling of "PARallel"). By SCPI rules the short form
71
+ # is the uppercase prefix, hence "PAL". The firmware enforces it: verified on
72
+ # a real ET433 (V2.00.2414.001), "FUNC:IMP:EQU PAR" is rejected and
73
+ # "FUNC:IMP:EQU?" answers "PALlel". Other vendors use their own syntax
74
+ # (e.g. Keysight encodes series/parallel in FUNC:IMP:TYPE CPD/CSRS), so we
75
+ # just match EastTester. The member is still called PAR for readability.
76
+ PAR = "PAL"
77
+
78
+
79
+ class DisplayPage(ParsedUnit):
80
+ """Front-panel display page as its SCPI token."""
81
+
82
+ MEAS = "MEAS"
83
+ SYST = "SYST"
et43/py.typed ADDED
File without changes
@@ -0,0 +1,317 @@
1
+ Metadata-Version: 2.4
2
+ Name: et43
3
+ Version: 0.1.0
4
+ Summary: Human-friendly Python client for EastTester ET43x LCR meters (ET430-ET433)
5
+ Keywords: lcr,lcr-meter,scpi,easttester,et43,et433,et43x,pyserial,instrument
6
+ Author: Vyacheslav Mayorov
7
+ Author-email: Vyacheslav Mayorov <vyacheslav.mayorov@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Classifier: Topic :: System :: Hardware
19
+ Classifier: Typing :: Typed
20
+ Requires-Dist: pyserial>=3.5
21
+ Requires-Python: >=3.14
22
+ Project-URL: Homepage, https://github.com/acc15/et43
23
+ Project-URL: Repository, https://github.com/acc15/et43
24
+ Project-URL: Issues, https://github.com/acc15/et43/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # et43
28
+
29
+ A human-friendly Python client for **EastTester ET43x** handheld LCR meters
30
+ (ET430/430B/431/432/**ET433**) over their USB virtual serial port.
31
+
32
+ > Unofficial, not affiliated with EastTester / Hangzhou Zhongchuang.
33
+
34
+ Instead of raw SCPI strings you get typed properties and methods: `mode`,
35
+ `frequency`, `level_mv`, `equivalent`, `fetch()` and so on.
36
+
37
+ - Single dependency: [`pyserial`](https://pypi.org/project/pyserial/) (no PyVISA).
38
+ - Fully typed (`py.typed`).
39
+ - Tested against a real ET433 (firmware `V2.00.2414.001`); see
40
+ [Firmware quirks](#firmware-quirks).
41
+
42
+ ### ET44x / ET45x
43
+
44
+ The benchtop ET44x/ET45x series speaks a very similar SCPI dialect, so most of
45
+ the client may work there too, but it has not been tested. Known differences,
46
+ based on the vendor's ET44/ET45 SCPI manual and the
47
+ [ET44.py](https://github.com/philpagel/ET44.py) driver:
48
+
49
+ - auto range is `FUNC:IMP:RANGe:AUTO ON|OFF` there, while the ET433 needs
50
+ `FUNC:IMP:RANGE AUTO`, so `measurement_range` will likely fail;
51
+ - ET44.py sends `THETA` for phase, which the ET433 rejects (it needs `THR`);
52
+ - the default baud rate is 9600 (`discover()` tries it as well).
53
+
54
+ Reports from ET44x/ET45x owners are welcome.
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install et43
60
+ # or
61
+ uv add et43
62
+ ```
63
+
64
+ Requires Python 3.14+.
65
+
66
+ ## Quick start
67
+
68
+ ```python
69
+ from et43 import ET43, Equivalent, PrimaryMode, SecondaryMode
70
+
71
+ with ET43.open("/dev/ttyUSB0").auto_remote() as lcr:
72
+ print(lcr.idn)
73
+
74
+ # Capacitance and ESR at 1 kHz, 1 V test level, series model
75
+ lcr.mode = PrimaryMode.C
76
+ lcr.secondary = SecondaryMode.ESR
77
+ lcr.frequency = 1_000
78
+ lcr.level_mv = 1_000
79
+ lcr.equivalent = Equivalent.SER
80
+
81
+ primary, secondary = lcr.fetch()
82
+ print(primary, secondary) # 1.234e-06 0.5
83
+ ```
84
+
85
+ Without a port name the client looks for the instrument itself:
86
+
87
+ ```python
88
+ lcr = ET43.open() # auto-discovery, tries 115200 and 9600 baud
89
+ ```
90
+
91
+ `ET43.open()` is a convenience factory: it creates and configures a
92
+ `serial.Serial` and hands it to the client. If you already have a port object,
93
+ pass it directly:
94
+
95
+ ```python
96
+ import serial
97
+ from et43 import ET43
98
+
99
+ link = serial.Serial("/dev/ttyUSB0", 115200, timeout=2)
100
+
101
+ with ET43(link).auto_remote() as lcr:
102
+ primary, secondary = lcr.fetch()
103
+ ```
104
+
105
+ The client always owns the port it is given: `close()` and leaving a `with`
106
+ block close it, even if you created the `serial.Serial` yourself. The response
107
+ timeout is taken from `link.timeout` (2 seconds if it is `None`). The port
108
+ object is available as `lcr.port`, and the device name as `lcr.port.port`.
109
+
110
+ The `ET43(link)` constructor performs no I/O. The first exchange happens on
111
+ `remote()`, `fetch()` or another explicit call.
112
+
113
+ On POSIX the port is locked with `flock` by default, so a second process gets
114
+ `serial.SerialException` instead of corrupting the exchange. Disable this with
115
+ `ET43.open(..., exclusive=False)`.
116
+
117
+ ## Settings
118
+
119
+ Every setting can be read and written:
120
+
121
+ ```python
122
+ from et43 import DisplayPage, Equivalent, PrimaryMode, SecondaryMode, Speed
123
+
124
+ lcr.mode = PrimaryMode.C
125
+ lcr.secondary = SecondaryMode.ESR
126
+ lcr.frequency = 10_000 # Hz
127
+ lcr.level_mv = 600 # mV
128
+ lcr.bias_mv = 100 # mV
129
+ lcr.equivalent = Equivalent.SER
130
+ lcr.speed = Speed.MED
131
+ lcr.measurement_range = None # None = AUTO, a number = range in ohms
132
+ lcr.relative_mode = True
133
+ lcr.display_page = DisplayPage.SYST
134
+ ```
135
+
136
+ Enum properties (`mode`, `secondary`, `equivalent`, `speed`, `display_page`)
137
+ accept enum members only, and their getters return enum members. They are
138
+ `StrEnum`s, so comparing with a string still works: `lcr.mode == "C"`.
139
+
140
+ `measurement_range` reads as `int | None`: `None` means `AUTO`, otherwise it is
141
+ the range resistance in ohms.
142
+
143
+ `frequency`, `level_mv` and `bias_mv` read as `float`, because SCPI returns them
144
+ in NR3 format. In practice the ET433 works in 1 Hz / 1 mV steps.
145
+
146
+ The client does not validate ranges: values are sent to the instrument as is.
147
+ Valid ranges depend on the model, and the instrument decides how to handle an
148
+ invalid value. For example, the ET433 silently ignores an out-of-range frequency
149
+ or bias, and answers `exec err` to an invalid level or measurement range.
150
+
151
+ Parsing helpers on the enums:
152
+
153
+ ```python
154
+ DisplayPage.SYST # "SYST", the SCPI token; the instrument answers "SYSTem"
155
+ PrimaryMode.from_string("c") # PrimaryMode.C
156
+ PrimaryMode.from_string("Cp") # PrimaryMode.C (older firmware answers)
157
+ Speed.from_string("MEDium") # Speed.MED (instrument answer)
158
+ ```
159
+
160
+ ## Measurements
161
+
162
+ `fetch()` sends exactly one `FETCH?` query and returns a `Reading`, a
163
+ two-field `NamedTuple`. Unpack it or use the field names:
164
+
165
+ ```python
166
+ primary, secondary = lcr.fetch()
167
+
168
+ reading = lcr.fetch()
169
+ print(reading.primary, reading.secondary)
170
+ ```
171
+
172
+ - `primary` is always a `float`.
173
+ - `secondary` is a `float` or `None`: in `DCR` mode, for example, the
174
+ instrument returns a single value. The tuple always has two items, so
175
+ `primary, secondary = ...` works in every mode.
176
+
177
+ `FETCH?` does not report the mode or units, so the client makes no extra
178
+ queries. Set the mode, the equivalent circuit and other parameters through
179
+ properties:
180
+
181
+ ```python
182
+ import time
183
+
184
+ lcr.mode = PrimaryMode.C
185
+ lcr.secondary = SecondaryMode.ESR
186
+ lcr.frequency = 1_000
187
+
188
+ time.sleep(0.5) # let the instrument settle
189
+ primary, secondary = lcr.fetch()
190
+ ```
191
+
192
+ `fetch()` neither configures the instrument nor waits: it is a single query.
193
+ Measurement loops and frequency sweeps are left to your code; see
194
+ [`scripts/`](https://github.com/acc15/et43/tree/master/scripts) in the repository for examples.
195
+
196
+ ## Remote and local mode
197
+
198
+ Neither `ET43.open()` nor `ET43(link)` switches the instrument to remote.
199
+ There are two ways to use the client:
200
+
201
+ - a plain `with` block: the instrument stays in local mode;
202
+ - `auto_remote()`: remote mode for the duration of the block, a guaranteed
203
+ return to local mode however the block ends, then the port is closed.
204
+
205
+ ```python
206
+ with ET43.open("/dev/ttyUSB0") as lcr:
207
+ print(lcr.fetch()) # local mode
208
+
209
+ with ET43.open("/dev/ttyUSB0").auto_remote() as lcr:
210
+ print(lcr.fetch()) # remote mode; SYST:LOCAL and close() afterwards
211
+ ```
212
+
213
+ `auto_remote()` sends `SYST:REMOTE` on entry and `SYST:LOCAL` on exit, also when
214
+ the block raises. This matters: in remote mode the front panel is locked, and it
215
+ must not stay locked after your program ends. If an error occurs while
216
+ returning to local mode after another error, the original error is the one you
217
+ see. Call it right on `open()`, since it closes the port on exit. To keep the
218
+ connection open across blocks, call `remote()` and `local()` yourself.
219
+
220
+ In local mode the instrument answers queries (`FETCH?`, `FREQ?` and so on) but
221
+ **ignores configuration commands**. The client reports this as an error rather
222
+ than pretending the setting was applied:
223
+
224
+ ```python
225
+ with ET43.open("/dev/ttyUSB0") as lcr:
226
+ print(lcr.fetch()) # queries work
227
+ lcr.frequency = 10_000 # raises TimeoutError in local mode
228
+ ```
229
+
230
+ ## Calibration
231
+
232
+ ```python
233
+ lcr.calibrate() # CORR:EXEC
234
+ ```
235
+
236
+ The instrument detects what is connected, open or short, by itself. Connect open
237
+ leads and calibrate, then short the leads and calibrate again.
238
+ **This overwrites the stored open/short correction data.**
239
+
240
+ `calibrate()` waits up to 45 seconds for the `exec success` acknowledgement
241
+ (a real ET433 takes about 45 seconds). The timeout can be changed:
242
+
243
+ ```python
244
+ lcr.calibrate(timeout=60)
245
+ ```
246
+
247
+ ## Thread safety
248
+
249
+ The client is not thread-safe: use one instance from one thread. If several
250
+ threads need the instrument, serialize calls with your own `threading.Lock`,
251
+ for example around `fetch()` and property writes.
252
+
253
+ ## Firmware quirks
254
+
255
+ Verified on a real ET433 with firmware `V2.00.2414.001`:
256
+
257
+ - **Frequency:** 10–100000 Hz. 10 Hz really works and gives correct C/ESR on a
258
+ 2.2 µF electrolytic capacitor. Out-of-range values are ignored: the instrument
259
+ answers `exec success` but keeps the old frequency. The ET432 range differs.
260
+ - **Bias:** 0–1000 mV. The older PDF says 0–500 mV, but the V2 manual and the
261
+ instrument allow 0–1000 mV. Out-of-range values are ignored, fractional
262
+ values are truncated to whole millivolts.
263
+ - **ECAP mode** enables an internal 1 V bias, which may stay on after switching
264
+ to another mode. Check `lcr.bias_mv` if it matters.
265
+ - **Level:** 0–1100 mV in whole millivolts. `1101`, negative and fractional
266
+ values (including `0.001` mV) are rejected with `exec err`.
267
+ - **Baud rate:** 115200. The manual mentions 9600, so `discover()` tries both.
268
+ - **`FETCH?`** returns **two** comma-separated values (`primary,secondary`),
269
+ while the older SCPI PDF describes four. In `DCR` mode it returns **one**
270
+ value, and `Reading.secondary` is `None`. Mode and units are not included.
271
+ - **Line terminators** vary between responses (`\n` and `\r`); the reader
272
+ accepts both.
273
+ - **Parallel equivalent circuit** is spelled `PAL`: `FUNC:IMP:EQU PAR` is
274
+ rejected, and `FUNC:IMP:EQU?` answers `SERial` or `PALlel`. This is an
275
+ EastTester misspelling of "parallel", shared by the ET44x/ET45x and present
276
+ in their SCPI manual. The API member is `Equivalent.PAR`, and its value is
277
+ `"PAL"`, the token the instrument accepts. `from_string` understands `PAR`,
278
+ `PAL` and `PALlel`.
279
+ - **Phase:** `FUNC:IMP:B THETA` is rejected; use `THR`. Phase is returned in
280
+ **degrees**.
281
+ - **Auto range:** `FUNC:IMP:RANGE:AUTO` does not work; the correct form is
282
+ `FUNC:IMP:RANGE AUTO`.
283
+ - **`AUTO` mode** does not report which parameter was actually measured, and
284
+ `fetch()` returns bare numbers. Set the mode explicitly (`C`, `L`, `R`, `Z`)
285
+ if you need units and meaningful labels.
286
+
287
+ ## Development
288
+
289
+ The project uses [uv](https://docs.astral.sh/uv/) and
290
+ [just](https://github.com/casey/just).
291
+
292
+ ```bash
293
+ uv sync # create .venv with the package and dev tools
294
+ just check # ty + ruff + pytest
295
+ just build # check, then build sdist and wheel into dist/
296
+ ```
297
+
298
+ The tests do not need an instrument. The example scripts do:
299
+
300
+ ```bash
301
+ uv run scripts/basic.py
302
+ uv run scripts/sweep_csv.py
303
+ ```
304
+
305
+ Project layout:
306
+
307
+ ```
308
+ src/et43/
309
+ client.py # ET43: transport, settings, fetch, remote/local
310
+ enums.py # PrimaryMode, SecondaryMode, Speed, Equivalent, DisplayPage
311
+ scripts/ # usage examples (not part of the package)
312
+ tests/
313
+ ```
314
+
315
+ ## License
316
+
317
+ [MIT](https://github.com/acc15/et43/blob/master/LICENSE)
@@ -0,0 +1,8 @@
1
+ et43/__init__.py,sha256=ok40r1j-Tz5w9kHGJZ3Y-Osfum8Rf9PL_dQULmVQpg0,368
2
+ et43/client.py,sha256=V1x4cTBHTnzfzJbca4-1cwTYp0uoAOVTlYvkpelOkDk,15463
3
+ et43/enums.py,sha256=gEv1HNZp22UdAhMSiSbftl8HLIM2ysHH6f9RWEHAKSg,2401
4
+ et43/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ et43-0.1.0.dist-info/licenses/LICENSE,sha256=hMN05qI_9ROg7FMr8r69SMFy7g1hUscyc0kHhyzYDLo,1075
6
+ et43-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
7
+ et43-0.1.0.dist-info/METADATA,sha256=Dl5hQmEJ0xyoZE5uDY8H7Ktc6Zh5AX9MN4l_TbU0Oxc,11317
8
+ et43-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vyacheslav Mayorov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.