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 +21 -0
- et43/client.py +440 -0
- et43/enums.py +83 -0
- et43/py.typed +0 -0
- et43-0.1.0.dist-info/METADATA +317 -0
- et43-0.1.0.dist-info/RECORD +8 -0
- et43-0.1.0.dist-info/WHEEL +4 -0
- et43-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|