scietex.hal.dac 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.
- scietex/hal/dac/__helpers.py +195 -0
- scietex/hal/dac/__init__.py +30 -0
- scietex/hal/dac/__logger.py +80 -0
- scietex/hal/dac/channel.py +296 -0
- scietex/hal/dac/dac.py +249 -0
- scietex/hal/dac/errors.py +13 -0
- scietex/hal/dac/mcp4xxx/__init__.py +5 -0
- scietex/hal/dac/mcp4xxx/mcp4xx1.py +58 -0
- scietex/hal/dac/mcp4xxx/mcp4xxx.py +135 -0
- scietex/hal/dac/py.typed +0 -0
- scietex/hal/dac/version.py +8 -0
- scietex_hal_dac-0.1.0.dist-info/METADATA +230 -0
- scietex_hal_dac-0.1.0.dist-info/RECORD +16 -0
- scietex_hal_dac-0.1.0.dist-info/WHEEL +5 -0
- scietex_hal_dac-0.1.0.dist-info/licenses/LICENSE +21 -0
- scietex_hal_dac-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Internal validation helpers shared by the package.
|
|
2
|
+
|
|
3
|
+
These functions are not part of the public API. They check the type of an
|
|
4
|
+
argument first, raising `TypeError` for anything that is not a real number, and
|
|
5
|
+
its value second, raising `ValueError` for infinities, `NaN`, and values outside
|
|
6
|
+
the requested range.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from math import isnan
|
|
10
|
+
from numbers import Integral, Real
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_number(num: float | int | Real) -> float | int:
|
|
14
|
+
"""
|
|
15
|
+
Check that `num` is a finite real number.
|
|
16
|
+
|
|
17
|
+
Infinity and `NaN` are rejected. `bool` is rejected as well even though it
|
|
18
|
+
is an `int` subclass: `True` and `False` are flags, not measurements, and
|
|
19
|
+
accepting them as numbers hides caller mistakes. Both `+0.0` and `-0.0` are
|
|
20
|
+
returned as `0.0`; integers are returned as int, all other numbers as float.
|
|
21
|
+
|
|
22
|
+
:param num: variable to check.
|
|
23
|
+
:return: Unchanged `num` as int or float.
|
|
24
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
25
|
+
:raises ValueError: If `num` is infinite or `NaN`.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(num, bool):
|
|
28
|
+
raise TypeError(
|
|
29
|
+
f"Expected a finite integer or float number, not bool {num!r} "
|
|
30
|
+
f"of type {type(num)}"
|
|
31
|
+
)
|
|
32
|
+
if not isinstance(num, Real):
|
|
33
|
+
raise TypeError(f"Expected a finite integer or float number, got {type(num)}")
|
|
34
|
+
if isinstance(num, Integral):
|
|
35
|
+
return int(num)
|
|
36
|
+
if num in [float("inf"), float("-inf")]:
|
|
37
|
+
raise ValueError(f"Expected a finite integer or float number, got {num}")
|
|
38
|
+
if isnan(num):
|
|
39
|
+
raise ValueError("Expected a finite integer or float number, not NaN")
|
|
40
|
+
if num == 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
return float(num)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_integer(num: float | int | Real) -> int:
|
|
46
|
+
"""
|
|
47
|
+
Check that `num` is an integer and convert it to int.
|
|
48
|
+
|
|
49
|
+
:param num: Number (either int or float) to be checked.
|
|
50
|
+
:return: int representation of `num`.
|
|
51
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
52
|
+
:raises ValueError: If `num` is infinite, `NaN`, or has a fractional part.
|
|
53
|
+
"""
|
|
54
|
+
num = check_number(num)
|
|
55
|
+
if isinstance(num, float) and not num.is_integer():
|
|
56
|
+
raise ValueError(f"Expected an integer number, got {num}")
|
|
57
|
+
return int(num)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_bool(value: bool) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Check that `value` is a boolean.
|
|
63
|
+
|
|
64
|
+
:param value: variable to check.
|
|
65
|
+
:return: Unchanged `value` as bool.
|
|
66
|
+
:raises TypeError: If `value` is not a boolean.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(value, bool):
|
|
69
|
+
raise TypeError(f"Expected a boolean, got {value!r} of type {type(value)}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
LABEL_MAX_LENGTH = 64
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_label(value: str) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Check that `value` is a non-empty string usable as a channel label.
|
|
79
|
+
|
|
80
|
+
Non-string input is rejected instead of coerced, so a label can never be
|
|
81
|
+
confused with a channel number. Surrounding whitespace is stripped and the
|
|
82
|
+
stripped text is returned, so `" a "` and `"a"` are the same label and an
|
|
83
|
+
all-whitespace string is empty.
|
|
84
|
+
|
|
85
|
+
:param value: Label to check.
|
|
86
|
+
:return: Stripped label as str.
|
|
87
|
+
:raises TypeError: If `value` is not a str.
|
|
88
|
+
:raises ValueError: If `value` is empty after stripping, or longer than
|
|
89
|
+
`LABEL_MAX_LENGTH` characters.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(value, str):
|
|
92
|
+
raise TypeError(f"Expected a string label, got {value!r} of type {type(value)}")
|
|
93
|
+
text = value.strip()
|
|
94
|
+
if not text:
|
|
95
|
+
raise ValueError("Expected a non-empty label")
|
|
96
|
+
if len(text) > LABEL_MAX_LENGTH:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Expected a label of at most {LABEL_MAX_LENGTH} characters, "
|
|
99
|
+
f"got {len(text)}"
|
|
100
|
+
)
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_positive(num: float | int) -> float | int:
|
|
105
|
+
"""
|
|
106
|
+
Check that `num` is a positive number.
|
|
107
|
+
|
|
108
|
+
`bool` is rejected as a type error even though it is an `int` subclass; pass
|
|
109
|
+
`1` explicitly when a numeric flag is intended.
|
|
110
|
+
|
|
111
|
+
:param num: Either float or int number.
|
|
112
|
+
:return: `num` unchanged.
|
|
113
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
114
|
+
:raises ValueError: If `num` is zero, negative, infinite, or `NaN`.
|
|
115
|
+
"""
|
|
116
|
+
num = check_number(num)
|
|
117
|
+
if num <= 0:
|
|
118
|
+
raise ValueError(f"Expected a positive number, got {num}")
|
|
119
|
+
return num
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clamp(
|
|
123
|
+
value: float | int, min_value: float | int, max_value: float | int
|
|
124
|
+
) -> float | int:
|
|
125
|
+
"""
|
|
126
|
+
Clamp `value` to the range between `min_value` and `max_value`.
|
|
127
|
+
|
|
128
|
+
The bounds may be passed in any order: swapped bounds are normalized before
|
|
129
|
+
the comparison. A float `value` always stays a float, even when it is
|
|
130
|
+
clamped to an int bound. All three arguments reject `bool` even though it
|
|
131
|
+
is an `int` subclass; pass `0` or `1` explicitly when a numeric flag is
|
|
132
|
+
intended.
|
|
133
|
+
|
|
134
|
+
:param value: Either float or int number to be coerced.
|
|
135
|
+
:param min_value: Lower coerce range boundary.
|
|
136
|
+
:param max_value: Upper coerce range boundary.
|
|
137
|
+
:return: Value coerced to the given range.
|
|
138
|
+
:raises TypeError: If an argument is not a real number, or is a `bool`.
|
|
139
|
+
:raises ValueError: If an argument is infinite or `NaN`.
|
|
140
|
+
"""
|
|
141
|
+
value = check_number(value)
|
|
142
|
+
min_value = check_number(min_value)
|
|
143
|
+
max_value = check_number(max_value)
|
|
144
|
+
if min_value > max_value:
|
|
145
|
+
min_value, max_value = max_value, min_value
|
|
146
|
+
if value < min_value:
|
|
147
|
+
if isinstance(value, float):
|
|
148
|
+
return float(min_value)
|
|
149
|
+
return min_value
|
|
150
|
+
if value > max_value:
|
|
151
|
+
if isinstance(value, float):
|
|
152
|
+
return float(max_value)
|
|
153
|
+
return max_value
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_reference(value: float | None) -> float | None:
|
|
158
|
+
"""
|
|
159
|
+
Check an optional reference: `None` passes through, a number must be positive.
|
|
160
|
+
|
|
161
|
+
The reference is unit-neutral: it is a full-scale value in volts for a
|
|
162
|
+
voltage output and in amperes for a current output, so only its positivity
|
|
163
|
+
is enforced here.
|
|
164
|
+
|
|
165
|
+
:param value: Reference value, or None when the scaling is unknown.
|
|
166
|
+
:return: `None`, or the reference as float.
|
|
167
|
+
:raises TypeError: If `value` is not a real number, or is a `bool`.
|
|
168
|
+
:raises ValueError: If `value` is zero, negative, infinite, or `NaN`.
|
|
169
|
+
"""
|
|
170
|
+
if value is None:
|
|
171
|
+
return None
|
|
172
|
+
return float(check_positive(value))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def check_grounded(value: str) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Check the terminal a divider output is grounded at.
|
|
178
|
+
|
|
179
|
+
Only `"A"` and `"B"` are meaningful: grounding B makes the output rise with
|
|
180
|
+
the code, grounding A makes it fall. Anything else is rejected instead of
|
|
181
|
+
coerced, so a typo cannot silently pick a direction.
|
|
182
|
+
|
|
183
|
+
:param value: Terminal name, either `"A"` or `"B"`.
|
|
184
|
+
:return: Unchanged terminal name.
|
|
185
|
+
:raises TypeError: If `value` is not a str.
|
|
186
|
+
:raises ValueError: If `value` is neither `"A"` nor `"B"`.
|
|
187
|
+
"""
|
|
188
|
+
if not isinstance(value, str):
|
|
189
|
+
raise TypeError(
|
|
190
|
+
f'Expected "A" or "B" as a grounded terminal, '
|
|
191
|
+
f"got {value!r} of type {type(value)}"
|
|
192
|
+
)
|
|
193
|
+
if value not in ("A", "B"):
|
|
194
|
+
raise ValueError(f'Expected "A" or "B" as a grounded terminal, got {value!r}')
|
|
195
|
+
return value
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Digital-to-analog converters."""
|
|
2
|
+
|
|
3
|
+
from .channel import (
|
|
4
|
+
CurrentChannel,
|
|
5
|
+
DACChannel,
|
|
6
|
+
DACUnit,
|
|
7
|
+
DividerChannel,
|
|
8
|
+
VoltageChannel,
|
|
9
|
+
)
|
|
10
|
+
from .dac import DAC
|
|
11
|
+
from .errors import DACCodeError, DACError, DACReferenceError
|
|
12
|
+
from .mcp4xxx import MCP4231, MCP4241, MCP4251, MCP4261
|
|
13
|
+
from .version import __version__
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"DAC",
|
|
18
|
+
"DACChannel",
|
|
19
|
+
"DACUnit",
|
|
20
|
+
"VoltageChannel",
|
|
21
|
+
"CurrentChannel",
|
|
22
|
+
"DividerChannel",
|
|
23
|
+
"DACError",
|
|
24
|
+
"DACReferenceError",
|
|
25
|
+
"DACCodeError",
|
|
26
|
+
"MCP4231",
|
|
27
|
+
"MCP4241",
|
|
28
|
+
"MCP4251",
|
|
29
|
+
"MCP4261",
|
|
30
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Logger factory used by every device class.
|
|
2
|
+
|
|
3
|
+
Each device gets its own named logger so that a single device can be traced
|
|
4
|
+
without enabling logging for the whole package.
|
|
5
|
+
|
|
6
|
+
Loggers keep ``propagate = True``: a record emitted here also reaches the
|
|
7
|
+
application's root handlers. If the application installs its own handler at the
|
|
8
|
+
root (or on a parent logger), the same record may be printed twice. Disable
|
|
9
|
+
propagation on the returned logger if that is not wanted.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
# Guards handler installation so that two threads calling `get_logger` for the
|
|
16
|
+
# same label cannot attach two handlers.
|
|
17
|
+
_LOCK = threading.Lock()
|
|
18
|
+
|
|
19
|
+
_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
20
|
+
|
|
21
|
+
# Marks the one handler this module owns. Tagging the handler keeps the package
|
|
22
|
+
# handler distinguishable from handlers the application installs on the same
|
|
23
|
+
# logger, so an explicit level is never applied to application handlers.
|
|
24
|
+
_PACKAGE_HANDLER_ATTR = "_dac_package_handler"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _package_handler(logger: logging.Logger) -> logging.Handler | None:
|
|
28
|
+
"""Return the handler installed by this module, if it is still attached."""
|
|
29
|
+
for handler in logger.handlers:
|
|
30
|
+
if getattr(handler, _PACKAGE_HANDLER_ATTR, False):
|
|
31
|
+
return handler
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_logger(label: str, log_level: int | str | None = None) -> logging.Logger:
|
|
36
|
+
"""
|
|
37
|
+
Return the named logger `label`, configured once.
|
|
38
|
+
|
|
39
|
+
The level defaults to `logging.ERROR` when `log_level` is None. On first use
|
|
40
|
+
the logger gets exactly one tagged `logging.StreamHandler` with the package
|
|
41
|
+
format. Later calls with `log_level=None` leave the level and handlers
|
|
42
|
+
untouched unless the package handler was removed, in which case it is
|
|
43
|
+
re-attached at the logger's current level. An explicit `log_level` is
|
|
44
|
+
applied on every call to the logger and to the package handler only;
|
|
45
|
+
handlers installed by the application are never re-leveled. Any int level,
|
|
46
|
+
including `logging.NOTSET`, is applied as given; only `None` selects the
|
|
47
|
+
default `logging.ERROR`.
|
|
48
|
+
|
|
49
|
+
The logger keeps ``propagate = True``, so its records also reach the
|
|
50
|
+
application's root handlers and may be duplicated if the application adds
|
|
51
|
+
its own handler.
|
|
52
|
+
|
|
53
|
+
:param label: Logger name, also used to look up an already configured logger.
|
|
54
|
+
:param log_level: Logging level such as `logging.DEBUG`; defaults to `logging.ERROR`.
|
|
55
|
+
:return: Configured logger.
|
|
56
|
+
"""
|
|
57
|
+
logger = logging.getLogger(label)
|
|
58
|
+
with _LOCK:
|
|
59
|
+
handler = _package_handler(logger)
|
|
60
|
+
if handler is None:
|
|
61
|
+
if log_level is not None:
|
|
62
|
+
level = log_level
|
|
63
|
+
logger.setLevel(level)
|
|
64
|
+
elif logger.level == logging.NOTSET:
|
|
65
|
+
level = logging.ERROR
|
|
66
|
+
logger.setLevel(level)
|
|
67
|
+
else:
|
|
68
|
+
# Re-attach after application code cleared the handlers without
|
|
69
|
+
# discarding the level configured by an earlier call.
|
|
70
|
+
level = logger.level
|
|
71
|
+
handler = logging.StreamHandler()
|
|
72
|
+
handler.setFormatter(logging.Formatter(_FORMAT))
|
|
73
|
+
handler.setLevel(level)
|
|
74
|
+
setattr(handler, _PACKAGE_HANDLER_ATTR, True)
|
|
75
|
+
logger.addHandler(handler)
|
|
76
|
+
elif log_level is not None:
|
|
77
|
+
logger.setLevel(log_level)
|
|
78
|
+
handler.setLevel(log_level)
|
|
79
|
+
logger.propagate = True
|
|
80
|
+
return logger
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Channel objects for digital-to-analog converters."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
5
|
+
|
|
6
|
+
from .__helpers import (
|
|
7
|
+
check_grounded,
|
|
8
|
+
check_integer,
|
|
9
|
+
check_number,
|
|
10
|
+
check_reference,
|
|
11
|
+
clamp,
|
|
12
|
+
)
|
|
13
|
+
from .errors import DACCodeError, DACReferenceError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .dac import DAC
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DACUnit(str, Enum):
|
|
20
|
+
"""Physical quantity a channel drives."""
|
|
21
|
+
|
|
22
|
+
VOLTAGE = "V"
|
|
23
|
+
CURRENT = "A"
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
"""Return the unit symbol, so the enum prints like the bare suffix."""
|
|
27
|
+
return self.value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DACChannel:
|
|
31
|
+
"""
|
|
32
|
+
One output of a `DAC` device.
|
|
33
|
+
|
|
34
|
+
The channel keeps no register state: reading `value` asks the device every
|
|
35
|
+
time, and the setter writes the code through to it. `output` scales that
|
|
36
|
+
code by the reference and stays None while no reference is configured,
|
|
37
|
+
because the library will not guess a full scale.
|
|
38
|
+
|
|
39
|
+
A device may scale each channel differently, so `reference_override` wins
|
|
40
|
+
over the device reference for this channel only.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
UNIT: ClassVar[DACUnit] = DACUnit.VOLTAGE
|
|
44
|
+
|
|
45
|
+
def __init__(self, dac: "DAC", index: int) -> None:
|
|
46
|
+
self._dac = dac
|
|
47
|
+
self.index = check_integer(index)
|
|
48
|
+
self._reference_override: float | None = None
|
|
49
|
+
self._reported_code: int | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def max_code(self) -> int:
|
|
53
|
+
"""Largest raw code the device accepts for this channel."""
|
|
54
|
+
return self._dac.max_code
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def unit(self) -> DACUnit:
|
|
58
|
+
"""Physical quantity this channel drives."""
|
|
59
|
+
return self.UNIT
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def reference_override(self) -> float | None:
|
|
63
|
+
"""Per-channel reference that wins over the device reference."""
|
|
64
|
+
return self._reference_override
|
|
65
|
+
|
|
66
|
+
@reference_override.setter
|
|
67
|
+
def reference_override(self, reference: float | None) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Set a per-channel reference, or clear it with None.
|
|
70
|
+
|
|
71
|
+
:param reference: Positive reference, or None to fall back to the
|
|
72
|
+
device reference (float | None).
|
|
73
|
+
:raises TypeError: If `reference` is not a real number, or is a `bool`.
|
|
74
|
+
:raises ValueError: If `reference` is zero, negative, infinite, or `NaN`.
|
|
75
|
+
"""
|
|
76
|
+
self._reference_override = check_reference(reference)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def reference(self) -> float | None:
|
|
80
|
+
"""Reference this channel scales by: the override, else the device's."""
|
|
81
|
+
override = self.reference_override
|
|
82
|
+
if override is not None:
|
|
83
|
+
return override
|
|
84
|
+
return self._dac.reference
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def value(self) -> int:
|
|
88
|
+
"""
|
|
89
|
+
Raw code of the output, read from the device.
|
|
90
|
+
|
|
91
|
+
:return: Code in `0..max_code` (int).
|
|
92
|
+
"""
|
|
93
|
+
return self._read_code()
|
|
94
|
+
|
|
95
|
+
@value.setter
|
|
96
|
+
def value(self, code: int) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Write a raw code to the device.
|
|
99
|
+
|
|
100
|
+
:param code: Code in `0..max_code` (int).
|
|
101
|
+
:raises TypeError: If `code` is not a real number, or is a `bool`.
|
|
102
|
+
:raises ValueError: If `code` is fractional, infinite, or `NaN`.
|
|
103
|
+
:raises DACCodeError: If `code` is outside `0..max_code`.
|
|
104
|
+
"""
|
|
105
|
+
self._reported_code = self._write_code(self._check_code(code))
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def output(self) -> float | None:
|
|
109
|
+
"""
|
|
110
|
+
Canonical output scaled by the channel reference.
|
|
111
|
+
|
|
112
|
+
Units follow the channel: volts for a voltage output, amperes for a
|
|
113
|
+
current output. The value is the code fraction times the reference, so
|
|
114
|
+
the top code reaches the reference itself.
|
|
115
|
+
|
|
116
|
+
:return: Scaled output, or None when the channel has no reference.
|
|
117
|
+
"""
|
|
118
|
+
reference = self.reference
|
|
119
|
+
if reference is None:
|
|
120
|
+
return None
|
|
121
|
+
return self._code_to_fraction(self.value) * reference
|
|
122
|
+
|
|
123
|
+
@output.setter
|
|
124
|
+
def output(self, output: float | None) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Set the output from a scaled value.
|
|
127
|
+
|
|
128
|
+
The value is clamped into `0..reference` without an error, so a caller
|
|
129
|
+
asking for slightly more than full scale gets full scale instead of a
|
|
130
|
+
rejected write.
|
|
131
|
+
|
|
132
|
+
:param output: Output in the channel unit (float).
|
|
133
|
+
:raises TypeError: If `output` is not a real number, is a `bool`, or is
|
|
134
|
+
None.
|
|
135
|
+
:raises ValueError: If `output` is infinite or `NaN`.
|
|
136
|
+
:raises DACReferenceError: If the channel has no reference.
|
|
137
|
+
"""
|
|
138
|
+
reference = self.reference
|
|
139
|
+
if reference is None:
|
|
140
|
+
raise DACReferenceError(
|
|
141
|
+
f"Channel {self.index} has no reference configured; "
|
|
142
|
+
"set the device reference or a channel override first"
|
|
143
|
+
)
|
|
144
|
+
# None must reach check_number, which is what rejects it with TypeError.
|
|
145
|
+
value = clamp(check_number(cast(float, output)), 0.0, reference)
|
|
146
|
+
self.value = self._fraction_to_code(value / reference)
|
|
147
|
+
|
|
148
|
+
def _check_code(self, code: int) -> int:
|
|
149
|
+
"""
|
|
150
|
+
Validate a raw code before it reaches the device.
|
|
151
|
+
|
|
152
|
+
:param code: Candidate code (int).
|
|
153
|
+
:return: Code as int, unchanged.
|
|
154
|
+
:raises TypeError: If `code` is not a real number, or is a `bool`.
|
|
155
|
+
:raises ValueError: If `code` is fractional, infinite, or `NaN`.
|
|
156
|
+
:raises DACCodeError: If `code` is outside `0..max_code`.
|
|
157
|
+
"""
|
|
158
|
+
code = check_integer(code)
|
|
159
|
+
if not 0 <= code <= self.max_code:
|
|
160
|
+
raise DACCodeError(f"Expected a code within 0..{self.max_code}, got {code}")
|
|
161
|
+
return code
|
|
162
|
+
|
|
163
|
+
def _read_code(self) -> int:
|
|
164
|
+
"""
|
|
165
|
+
Read the raw code from the device.
|
|
166
|
+
|
|
167
|
+
:return: Code in `0..max_code`.
|
|
168
|
+
:raises NotImplementedError: Always, the base class has no codec.
|
|
169
|
+
"""
|
|
170
|
+
raise NotImplementedError
|
|
171
|
+
|
|
172
|
+
def _write_code(self, code: int) -> int:
|
|
173
|
+
"""
|
|
174
|
+
Write a validated raw code and return the code the device reports.
|
|
175
|
+
|
|
176
|
+
:param code: Code already validated by `_check_code`.
|
|
177
|
+
:return: Code the device reports after the write.
|
|
178
|
+
:raises NotImplementedError: Always, the base class has no codec.
|
|
179
|
+
"""
|
|
180
|
+
raise NotImplementedError
|
|
181
|
+
|
|
182
|
+
def _code_to_fraction(self, code: int) -> float:
|
|
183
|
+
"""
|
|
184
|
+
Map a raw code to the fraction of full scale it selects.
|
|
185
|
+
|
|
186
|
+
:param code: Code in `0..max_code`.
|
|
187
|
+
:return: Fraction in `0..1`.
|
|
188
|
+
"""
|
|
189
|
+
return code / self.max_code
|
|
190
|
+
|
|
191
|
+
def _fraction_to_code(self, fraction: float) -> int:
|
|
192
|
+
"""
|
|
193
|
+
Map a fraction of full scale to the nearest raw code.
|
|
194
|
+
|
|
195
|
+
:param fraction: Fraction in `0..1`.
|
|
196
|
+
:return: Code in `0..max_code`.
|
|
197
|
+
"""
|
|
198
|
+
return int(round(fraction * self.max_code))
|
|
199
|
+
|
|
200
|
+
def __repr__(self) -> str:
|
|
201
|
+
"""Official string representation (used in debugging)."""
|
|
202
|
+
return (
|
|
203
|
+
f"{type(self).__name__}(index={self.index}, code={self._reported_code!r})"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class VoltageChannel(DACChannel):
|
|
208
|
+
"""Channel that drives a voltage output."""
|
|
209
|
+
|
|
210
|
+
UNIT: ClassVar[DACUnit] = DACUnit.VOLTAGE
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def voltage(self) -> float | None:
|
|
214
|
+
"""Output voltage in volts, an alias of `output`."""
|
|
215
|
+
return self.output
|
|
216
|
+
|
|
217
|
+
@voltage.setter
|
|
218
|
+
def voltage(self, voltage: float | None) -> None:
|
|
219
|
+
"""Set the output voltage in volts, an alias of `output`."""
|
|
220
|
+
self.output = voltage
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def v_ref(self) -> float | None:
|
|
224
|
+
"""Reference voltage this channel scales by."""
|
|
225
|
+
return self.reference
|
|
226
|
+
|
|
227
|
+
@v_ref.setter
|
|
228
|
+
def v_ref(self, v_ref: float | None) -> None:
|
|
229
|
+
"""Set a per-channel reference voltage, an alias of `reference_override`."""
|
|
230
|
+
self.reference_override = v_ref
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CurrentChannel(DACChannel):
|
|
234
|
+
"""Channel that drives a current output."""
|
|
235
|
+
|
|
236
|
+
UNIT: ClassVar[DACUnit] = DACUnit.CURRENT
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def current(self) -> float | None:
|
|
240
|
+
"""Output current in amperes, an alias of `output`."""
|
|
241
|
+
return self.output
|
|
242
|
+
|
|
243
|
+
@current.setter
|
|
244
|
+
def current(self, current: float | None) -> None:
|
|
245
|
+
"""Set the output current in amperes, an alias of `output`."""
|
|
246
|
+
self.output = current
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def i_full_scale(self) -> float | None:
|
|
250
|
+
"""Full-scale current in amperes this channel scales by."""
|
|
251
|
+
return self.reference
|
|
252
|
+
|
|
253
|
+
@i_full_scale.setter
|
|
254
|
+
def i_full_scale(self, i_full_scale: float | None) -> None:
|
|
255
|
+
"""Set a per-channel full-scale current, an alias of `reference_override`."""
|
|
256
|
+
self.reference_override = i_full_scale
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class DividerChannel(VoltageChannel):
|
|
260
|
+
"""
|
|
261
|
+
Voltage output of a resistor divider with one terminal grounded.
|
|
262
|
+
|
|
263
|
+
Grounding B makes the output rise from 0 V at code 0 to the reference at
|
|
264
|
+
full code; grounding A mirrors that curve, so the output falls from the
|
|
265
|
+
reference at code 0 to 0 V at full code.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, dac: "DAC", index: int, *, grounded: str) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Build a divider channel.
|
|
271
|
+
|
|
272
|
+
:param dac: Device the channel belongs to (DAC).
|
|
273
|
+
:param index: Channel index, 0-based (int).
|
|
274
|
+
:param grounded: Terminal tied to ground, `"A"` or `"B"` (str).
|
|
275
|
+
:raises TypeError: If `grounded` is not a str.
|
|
276
|
+
:raises ValueError: If `grounded` is neither `"A"` nor `"B"`.
|
|
277
|
+
"""
|
|
278
|
+
super().__init__(dac, index)
|
|
279
|
+
self._grounded = check_grounded(grounded)
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def grounded(self) -> str:
|
|
283
|
+
"""Terminal tied to ground: `"B"` rises with the code, `"A"` falls."""
|
|
284
|
+
return self._grounded
|
|
285
|
+
|
|
286
|
+
def _code_to_fraction(self, code: int) -> float:
|
|
287
|
+
"""Map a code to the output fraction of the reference."""
|
|
288
|
+
if self._grounded == "B":
|
|
289
|
+
return code / self.max_code
|
|
290
|
+
return (self.max_code - code) / self.max_code
|
|
291
|
+
|
|
292
|
+
def _fraction_to_code(self, fraction: float) -> int:
|
|
293
|
+
"""Map an output fraction of the reference to the nearest code."""
|
|
294
|
+
if self._grounded == "B":
|
|
295
|
+
return int(round(fraction * self.max_code))
|
|
296
|
+
return int(round((1 - fraction) * self.max_code))
|
scietex/hal/dac/dac.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Base class for digital-to-analog converters."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .__helpers import check_integer, check_label, check_reference
|
|
8
|
+
from .__logger import get_logger
|
|
9
|
+
from .channel import DACChannel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DAC:
|
|
13
|
+
"""
|
|
14
|
+
Container for the channels of a digital-to-analog converter.
|
|
15
|
+
|
|
16
|
+
The device is transport-agnostic: it wraps a backend object that owns the
|
|
17
|
+
bus, and its channels are built once from the class-level geometry.
|
|
18
|
+
`channels` holds them in index order and never changes.
|
|
19
|
+
|
|
20
|
+
`reference` is unit-neutral: it is the full scale the outputs map to, in
|
|
21
|
+
volts for a voltage device and in amperes for a current device. While it is
|
|
22
|
+
None the scaled `output` of every channel is None, because the library will
|
|
23
|
+
not guess a full scale.
|
|
24
|
+
|
|
25
|
+
The object can be used as a context manager; leaving the block closes the
|
|
26
|
+
backend.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_CHANNELS: int = 0
|
|
30
|
+
_RESOLUTION: int = 8
|
|
31
|
+
_CHANNEL_CLASS: type[DACChannel] = DACChannel
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
backend: object,
|
|
37
|
+
reference: float | None = None,
|
|
38
|
+
channel_count: int | None = None,
|
|
39
|
+
label: str | None = None,
|
|
40
|
+
log_level: int | str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Build the device around a backend that owns the bus.
|
|
44
|
+
|
|
45
|
+
:param backend: Object the device delegates bus access and lifecycle to;
|
|
46
|
+
it needs `close()` and may expose `transport`, `spi`, and `closed`.
|
|
47
|
+
:param reference: Full-scale value the channels scale by, or None while
|
|
48
|
+
it is unknown (float | None).
|
|
49
|
+
:param channel_count: Number of channels to expose, defaults to the
|
|
50
|
+
device geometry (int | None).
|
|
51
|
+
:param label: Logger name, defaults to the class name (str).
|
|
52
|
+
:param log_level: Logging level such as `logging.DEBUG`; defaults to
|
|
53
|
+
`logging.ERROR`.
|
|
54
|
+
:raises TypeError: If `reference` or `label` has the wrong type.
|
|
55
|
+
:raises ValueError: If `reference` is not positive, `label` is empty,
|
|
56
|
+
or `channel_count` is outside `1..channels`.
|
|
57
|
+
"""
|
|
58
|
+
# Duck-typed on purpose: the base class only probes attributes such as
|
|
59
|
+
# `transport` and calls `close()`, and a device family supplies the
|
|
60
|
+
# concrete backend.
|
|
61
|
+
self._backend: Any = backend
|
|
62
|
+
self._closed = False
|
|
63
|
+
self.reference = reference
|
|
64
|
+
self.label = None if label is None else check_label(label)
|
|
65
|
+
self.logger = get_logger(self.label or type(self).__name__, log_level)
|
|
66
|
+
count = (
|
|
67
|
+
self._CHANNELS if channel_count is None else check_integer(channel_count)
|
|
68
|
+
)
|
|
69
|
+
if not 1 <= count <= self._CHANNELS:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Expected a channel count within 1..{self._CHANNELS}, got {count}"
|
|
72
|
+
)
|
|
73
|
+
self.channels: tuple[DACChannel, ...] = tuple(
|
|
74
|
+
self._make_channel(index) for index in range(count)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def reference(self) -> float | None:
|
|
79
|
+
"""Full scale the channels map to, or None when it is unknown."""
|
|
80
|
+
return self._reference
|
|
81
|
+
|
|
82
|
+
@reference.setter
|
|
83
|
+
def reference(self, reference: float | None) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Set the full scale used by the channel `output` properties.
|
|
86
|
+
|
|
87
|
+
:param reference: Positive full scale, or None (float | None).
|
|
88
|
+
:raises TypeError: If `reference` is not a real number, or is a `bool`.
|
|
89
|
+
:raises ValueError: If `reference` is zero, negative, infinite, or `NaN`.
|
|
90
|
+
"""
|
|
91
|
+
self._reference = check_reference(reference)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def v_ref(self) -> float | None:
|
|
95
|
+
"""Alias of `reference`, the conventional name on a voltage device."""
|
|
96
|
+
return self.reference
|
|
97
|
+
|
|
98
|
+
@v_ref.setter
|
|
99
|
+
def v_ref(self, v_ref: float | None) -> None:
|
|
100
|
+
"""Set the full scale, an alias of `reference`."""
|
|
101
|
+
self.reference = v_ref
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def resolution(self) -> int:
|
|
105
|
+
"""Number of bits in a raw code."""
|
|
106
|
+
return self._RESOLUTION
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def max_code(self) -> int:
|
|
110
|
+
"""Largest raw code the device accepts, `(1 << resolution) - 1`."""
|
|
111
|
+
return (1 << self.resolution) - 1
|
|
112
|
+
|
|
113
|
+
def channel(self, index: int) -> DACChannel:
|
|
114
|
+
"""
|
|
115
|
+
Return the channel at `index`.
|
|
116
|
+
|
|
117
|
+
:param index: Channel index, 0-based (int).
|
|
118
|
+
:return: `DACChannel` for that output.
|
|
119
|
+
:raises IndexError: If `index` is outside `0..channels - 1`.
|
|
120
|
+
"""
|
|
121
|
+
if not 0 <= index < len(self.channels):
|
|
122
|
+
raise IndexError(f"Channel {index} does not exist")
|
|
123
|
+
return self.channels[index]
|
|
124
|
+
|
|
125
|
+
def read_all(self) -> tuple[int, ...]:
|
|
126
|
+
"""
|
|
127
|
+
Read every channel.
|
|
128
|
+
|
|
129
|
+
No value is cached: each element is a separate bus transaction.
|
|
130
|
+
|
|
131
|
+
:return: Raw codes in channel index order (tuple of int).
|
|
132
|
+
"""
|
|
133
|
+
return tuple(channel.value for channel in self.channels)
|
|
134
|
+
|
|
135
|
+
def write_all(self, values: Sequence[int]) -> tuple[int, ...]:
|
|
136
|
+
"""
|
|
137
|
+
Write one raw code per channel.
|
|
138
|
+
|
|
139
|
+
The whole sequence is validated before the first bus transaction, so a
|
|
140
|
+
rejected element cannot leave the device partially updated.
|
|
141
|
+
|
|
142
|
+
:param values: One code per channel (Sequence of int).
|
|
143
|
+
:return: Codes that were written, in channel index order.
|
|
144
|
+
:raises TypeError: If `values` is not a sequence, or an element is not
|
|
145
|
+
a real number, or is a `bool`.
|
|
146
|
+
:raises ValueError: If the length differs from the channel count, or an
|
|
147
|
+
element is fractional, infinite, or `NaN`.
|
|
148
|
+
:raises DACCodeError: If an element is outside `0..max_code`.
|
|
149
|
+
"""
|
|
150
|
+
if len(values) != len(self.channels):
|
|
151
|
+
raise ValueError(f"Expected {len(self.channels)} codes, got {len(values)}")
|
|
152
|
+
codes = tuple(
|
|
153
|
+
channel._check_code(value)
|
|
154
|
+
for channel, value in zip(self.channels, values, strict=True)
|
|
155
|
+
)
|
|
156
|
+
for channel, code in zip(self.channels, codes, strict=True):
|
|
157
|
+
channel.value = code
|
|
158
|
+
return codes
|
|
159
|
+
|
|
160
|
+
def _make_channel(self, index: int) -> DACChannel:
|
|
161
|
+
"""
|
|
162
|
+
Build the channel object for `index`.
|
|
163
|
+
|
|
164
|
+
:param index: Channel index, 0-based (int).
|
|
165
|
+
:return: Channel of the device's channel class.
|
|
166
|
+
"""
|
|
167
|
+
return self._CHANNEL_CLASS(self, index)
|
|
168
|
+
|
|
169
|
+
def _read_code(self, index: int) -> int:
|
|
170
|
+
"""
|
|
171
|
+
Read one channel as a raw code.
|
|
172
|
+
|
|
173
|
+
The base class has no frame format; a device family implements it.
|
|
174
|
+
|
|
175
|
+
:param index: Channel index, 0-based (int).
|
|
176
|
+
:return: Raw code of that channel.
|
|
177
|
+
:raises NotImplementedError: Always, the base class has no codec.
|
|
178
|
+
"""
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
def _write_code(self, index: int, code: int) -> int:
|
|
182
|
+
"""
|
|
183
|
+
Write one channel as a raw code.
|
|
184
|
+
|
|
185
|
+
The base class has no frame format; a device family implements it.
|
|
186
|
+
|
|
187
|
+
:param index: Channel index, 0-based (int).
|
|
188
|
+
:param code: Raw code already validated by the channel.
|
|
189
|
+
:return: Code the device reports after the write.
|
|
190
|
+
:raises NotImplementedError: Always, the base class has no codec.
|
|
191
|
+
"""
|
|
192
|
+
raise NotImplementedError
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def transport(self) -> Any:
|
|
196
|
+
"""Bus transport of the backend, or the backend when it exposes none."""
|
|
197
|
+
return getattr(self._backend, "transport", None) or self._backend
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def spi(self) -> Any:
|
|
201
|
+
"""Backend's SPI handle: its `spi`, then its `raw`, then the backend."""
|
|
202
|
+
spi = getattr(self._backend, "spi", None)
|
|
203
|
+
if spi is not None:
|
|
204
|
+
return spi
|
|
205
|
+
raw = getattr(self._backend, "raw", None)
|
|
206
|
+
return self._backend if raw is None else raw
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def closed(self) -> bool:
|
|
210
|
+
"""
|
|
211
|
+
True when `close()` has run or the backend reports itself closed.
|
|
212
|
+
|
|
213
|
+
A callable `closed` attribute is called first and its result is coerced
|
|
214
|
+
with `bool`. A backend whose `closed` probe raises counts as not closed;
|
|
215
|
+
the device's own flag still applies.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
reported = getattr(self._backend, "closed", False)
|
|
219
|
+
if callable(reported):
|
|
220
|
+
reported = reported()
|
|
221
|
+
reported = bool(reported)
|
|
222
|
+
except Exception: # noqa: BLE001 - a broken probe must not leak
|
|
223
|
+
reported = False
|
|
224
|
+
return self._closed or reported
|
|
225
|
+
|
|
226
|
+
def close(self) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Close the backend and mark the device closed.
|
|
229
|
+
|
|
230
|
+
The device is marked closed even when the backend's `close()` raises;
|
|
231
|
+
the exception is still propagated.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
self._backend.close()
|
|
235
|
+
finally:
|
|
236
|
+
self._closed = True
|
|
237
|
+
|
|
238
|
+
def __enter__(self) -> "DAC":
|
|
239
|
+
"""Return the device itself."""
|
|
240
|
+
return self
|
|
241
|
+
|
|
242
|
+
def __exit__(
|
|
243
|
+
self,
|
|
244
|
+
exc_type: type[BaseException] | None,
|
|
245
|
+
exc_value: BaseException | None,
|
|
246
|
+
traceback: TracebackType | None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Close the backend when leaving the context."""
|
|
249
|
+
self.close()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Errors raised by the DAC package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DACError(Exception):
|
|
5
|
+
"""Base class for errors raised by this package."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DACReferenceError(DACError, ValueError):
|
|
9
|
+
"""Raised when output is requested or set with no reference configured."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DACCodeError(DACError, ValueError):
|
|
13
|
+
"""Raised when a raw code falls outside `0..max_code`."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Dual-channel MCP4xx1 digital potentiometers."""
|
|
2
|
+
|
|
3
|
+
from BDPotentiometer.mcp4xxx.mcp4xx1 import (
|
|
4
|
+
MCP4231 as _BDMCP4231,
|
|
5
|
+
)
|
|
6
|
+
from BDPotentiometer.mcp4xxx.mcp4xx1 import (
|
|
7
|
+
MCP4241 as _BDMCP4241,
|
|
8
|
+
)
|
|
9
|
+
from BDPotentiometer.mcp4xxx.mcp4xx1 import (
|
|
10
|
+
MCP4251 as _BDMCP4251,
|
|
11
|
+
)
|
|
12
|
+
from BDPotentiometer.mcp4xxx.mcp4xx1 import (
|
|
13
|
+
MCP4261 as _BDMCP4261,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .mcp4xxx import MCP4xxx
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCP4xx1(MCP4xxx):
|
|
20
|
+
"""Base for the dual-channel members of the MCP4xx1 family."""
|
|
21
|
+
|
|
22
|
+
_CHANNELS = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCP4231(MCP4xx1):
|
|
26
|
+
"""MCP4231: two 7-bit volatile channels, codes `0..127`."""
|
|
27
|
+
|
|
28
|
+
_RESOLUTION = 7
|
|
29
|
+
_POT_CLASS = _BDMCP4231
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MCP4241(MCP4xx1):
|
|
33
|
+
"""MCP4241: two 7-bit channels, codes `0..127`.
|
|
34
|
+
|
|
35
|
+
The part keeps a non-volatile wiper value, but this package has no commit
|
|
36
|
+
path, so only the volatile register is written.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_RESOLUTION = 7
|
|
40
|
+
_POT_CLASS = _BDMCP4241
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MCP4251(MCP4xx1):
|
|
44
|
+
"""MCP4251: two 8-bit volatile channels, codes `0..255`."""
|
|
45
|
+
|
|
46
|
+
_RESOLUTION = 8
|
|
47
|
+
_POT_CLASS = _BDMCP4251
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MCP4261(MCP4xx1):
|
|
51
|
+
"""MCP4261: two 8-bit channels, codes `0..255`.
|
|
52
|
+
|
|
53
|
+
The part keeps a non-volatile wiper value, but this package has no commit
|
|
54
|
+
path, so only the volatile register is written.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_RESOLUTION = 8
|
|
58
|
+
_POT_CLASS = _BDMCP4261
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""MCP4xxx digital potentiometers exposed as DAC divider channels."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from scietex.hal.spi import SpiTransport
|
|
6
|
+
|
|
7
|
+
from ..__helpers import check_grounded, check_reference
|
|
8
|
+
from ..channel import DACChannel, DividerChannel
|
|
9
|
+
from ..dac import DAC
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MCP4xxxChannel(DividerChannel):
|
|
13
|
+
"""One wiper of an MCP4xxx potentiometer, wired as a grounded divider."""
|
|
14
|
+
|
|
15
|
+
def _read_code(self) -> int:
|
|
16
|
+
"""Read the wiper register; the wrapped device owns the frame format."""
|
|
17
|
+
return cast("MCP4xxx", self._dac).device.get_value(self.index)
|
|
18
|
+
|
|
19
|
+
def _write_code(self, code: int) -> int:
|
|
20
|
+
"""Write the wiper register and return the code read back."""
|
|
21
|
+
return cast("MCP4xxx", self._dac).device.set_value(self.index, code)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MCP4xxx(DAC):
|
|
25
|
+
"""
|
|
26
|
+
Base class for the dual-channel MCP4xxx digital potentiometers.
|
|
27
|
+
|
|
28
|
+
The device composes a `BDPotentiometer` part instead of subclassing it:
|
|
29
|
+
the wrapped object is built first, because it opens the bus and reads every
|
|
30
|
+
wiper register during construction, and is then handed to the DAC layer as
|
|
31
|
+
the backend. `reference` and `grounded` are validated before the wrapped
|
|
32
|
+
part is built, so a rejected keyword never touches the bus.
|
|
33
|
+
|
|
34
|
+
The chip classes narrow the raw code to `2**bits - 1`: the pot model counts
|
|
35
|
+
`2**bits` steps, but code `2**bits` is never written, so the top code is
|
|
36
|
+
127 on a 7-bit part and 255 on an 8-bit part.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_RESOLUTION: int
|
|
40
|
+
_POT_CLASS: type[Any]
|
|
41
|
+
_CHANNEL_CLASS = MCP4xxxChannel
|
|
42
|
+
|
|
43
|
+
def __init__( # noqa: PLR0913
|
|
44
|
+
self,
|
|
45
|
+
r_ab: float = 10e3,
|
|
46
|
+
channels: int | None = None,
|
|
47
|
+
*,
|
|
48
|
+
reference: float | None = None,
|
|
49
|
+
grounded: str = "B",
|
|
50
|
+
transport: SpiTransport | None = None,
|
|
51
|
+
spi: SpiTransport | None = None,
|
|
52
|
+
port: Any = None,
|
|
53
|
+
device: Any = None,
|
|
54
|
+
clock_pin: Any = None,
|
|
55
|
+
mosi_pin: Any = None,
|
|
56
|
+
miso_pin: Any = None,
|
|
57
|
+
select_pin: Any = None,
|
|
58
|
+
label: str | None = None,
|
|
59
|
+
log_level: int | str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Build the potentiometer and expose its wipers as divider channels.
|
|
63
|
+
|
|
64
|
+
:param r_ab: Total resistance in ohms; the wrapped part accepts 5k,
|
|
65
|
+
10k, 50k, and 100k (float).
|
|
66
|
+
:param channels: Channel count, 1..2; None uses every channel the part
|
|
67
|
+
provides (int | None).
|
|
68
|
+
:param reference: Full-scale voltage in volts, or None while unknown
|
|
69
|
+
(float | None).
|
|
70
|
+
:param grounded: Terminal tied to ground, `"A"` or `"B"`; grounding B
|
|
71
|
+
makes the output rise with the code (str).
|
|
72
|
+
:param transport: Canonical SPI transport, forwarded to the part
|
|
73
|
+
(SpiTransport | None).
|
|
74
|
+
:param spi: Legacy alias for `transport`, used when `transport` is
|
|
75
|
+
None (SpiTransport | None).
|
|
76
|
+
:param port: spidev bus number, used when no transport is supplied.
|
|
77
|
+
:param device: spidev chip-select line, used when no transport is
|
|
78
|
+
supplied.
|
|
79
|
+
:param clock_pin: GPIO clock pin; any pin keyword selects the gpiozero
|
|
80
|
+
backend.
|
|
81
|
+
:param mosi_pin: GPIO MOSI pin; selects the gpiozero backend.
|
|
82
|
+
:param miso_pin: GPIO MISO pin; selects the gpiozero backend.
|
|
83
|
+
:param select_pin: GPIO chip-select pin; selects the gpiozero backend.
|
|
84
|
+
:param label: Logger name, defaults to the class name (str).
|
|
85
|
+
:param log_level: Logging level such as `logging.DEBUG`; defaults to
|
|
86
|
+
`logging.ERROR`.
|
|
87
|
+
:raises TypeError: If `grounded` or `reference` has the wrong type, or
|
|
88
|
+
if `invert`/`max_value` is passed.
|
|
89
|
+
:raises ValueError: If `grounded` is not `"A"` or `"B"`, `reference`
|
|
90
|
+
is not positive, `r_ab` is unsupported, or `label` is empty.
|
|
91
|
+
:raises ImportError: If the optional package for the selected backend
|
|
92
|
+
is missing.
|
|
93
|
+
"""
|
|
94
|
+
self._grounded = check_grounded(grounded)
|
|
95
|
+
reference = check_reference(reference)
|
|
96
|
+
self._pot = self._POT_CLASS(
|
|
97
|
+
r_ab=r_ab,
|
|
98
|
+
channels=channels,
|
|
99
|
+
transport=transport,
|
|
100
|
+
spi=spi,
|
|
101
|
+
port=port,
|
|
102
|
+
device=device,
|
|
103
|
+
clock_pin=clock_pin,
|
|
104
|
+
mosi_pin=mosi_pin,
|
|
105
|
+
miso_pin=miso_pin,
|
|
106
|
+
select_pin=select_pin,
|
|
107
|
+
invert=False,
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
super().__init__(
|
|
111
|
+
backend=self._pot,
|
|
112
|
+
reference=reference,
|
|
113
|
+
channel_count=len(self._pot.channels),
|
|
114
|
+
label=label,
|
|
115
|
+
log_level=log_level,
|
|
116
|
+
)
|
|
117
|
+
except BaseException:
|
|
118
|
+
# The wrapped part owns the bus this constructor opened; release it
|
|
119
|
+
# when the DAC layer rejects a later keyword.
|
|
120
|
+
self._pot.close()
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def grounded(self) -> str:
|
|
125
|
+
"""Terminal tied to ground: `"B"` rises with the code, `"A"` falls."""
|
|
126
|
+
return self._grounded
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def device(self) -> Any:
|
|
130
|
+
"""The wrapped `BDPotentiometer` device, for escape-hatch access."""
|
|
131
|
+
return self._pot
|
|
132
|
+
|
|
133
|
+
def _make_channel(self, index: int) -> DACChannel:
|
|
134
|
+
"""Build a divider channel wired the same way as the device."""
|
|
135
|
+
return MCP4xxxChannel(self, index, grounded=self._grounded)
|
scietex/hal/dac/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scietex.hal.dac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Digital-to-analog converters and digital potentiometers
|
|
5
|
+
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Anton Bondarenko
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/bond-anton/scietex.hal.dac
|
|
28
|
+
Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.hal.dac/issues
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Requires-Python: >=3.10
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: BDPotentiometer<1.0.0,>=0.4.0
|
|
38
|
+
Requires-Dist: scietex.hal.spi<1.0.0,>=0.1.0
|
|
39
|
+
Provides-Extra: spi
|
|
40
|
+
Requires-Dist: scietex.hal.spi[spi]; extra == "spi"
|
|
41
|
+
Provides-Extra: gpiozero
|
|
42
|
+
Requires-Dist: scietex.hal.spi[gpiozero]; extra == "gpiozero"
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: tox>=4.46.0; extra == "dev"
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest; extra == "test"
|
|
47
|
+
Provides-Extra: lint
|
|
48
|
+
Requires-Dist: ruff==0.17.4; extra == "lint"
|
|
49
|
+
Requires-Dist: ty==0.0.99; extra == "lint"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# scietex.hal.dac
|
|
53
|
+
|
|
54
|
+
Digital-to-analog converters: a `DAC` device owns its output channels, and each
|
|
55
|
+
channel converts between a raw code and a scaled output. The package ships the
|
|
56
|
+
Microchip MCP4xx1 dual digital potentiometers, exposed as grounded resistor
|
|
57
|
+
dividers driven over a plain SPI transport.
|
|
58
|
+
|
|
59
|
+
The abstraction is unit-neutral: the device reference is a full scale in volts
|
|
60
|
+
for a voltage device and in amperes for a current device, and a channel may
|
|
61
|
+
override it. Scaled outputs stay `None` while no reference is configured.
|
|
62
|
+
|
|
63
|
+
## Parts
|
|
64
|
+
|
|
65
|
+
| Part | Codes | Wiper storage |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| MCP4231 | 7-bit, `0..127` | volatile |
|
|
68
|
+
| MCP4241 | 7-bit, `0..127` | non-volatile |
|
|
69
|
+
| MCP4251 | 8-bit, `0..255` | volatile |
|
|
70
|
+
| MCP4261 | 8-bit, `0..255` | non-volatile |
|
|
71
|
+
|
|
72
|
+
The non-volatile parts keep a wiper value across power cycles, but this package
|
|
73
|
+
has no commit path: only the volatile register is written, exactly as on the
|
|
74
|
+
volatile parts.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install scietex.hal.dac[spi] # Linux spidev backend
|
|
80
|
+
pip install scietex.hal.dac[gpiozero] # gpiozero backend
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The base package installs without either backend. A device built from the
|
|
84
|
+
default keywords needs `spidev`; pin keywords (`clock_pin`, `mosi_pin`,
|
|
85
|
+
`miso_pin`, `select_pin`) need `gpiozero`. A missing backend raises
|
|
86
|
+
`ImportError` naming the extra to install.
|
|
87
|
+
|
|
88
|
+
## Wiring
|
|
89
|
+
|
|
90
|
+
| Signal | Connection |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| SCLK | SPI clock |
|
|
93
|
+
| SDI | SPI MOSI |
|
|
94
|
+
| SDO | SPI MISO |
|
|
95
|
+
| CS | SPI chip select |
|
|
96
|
+
| V_DD | Supply, 2.7 V to 5.5 V |
|
|
97
|
+
| V_SS | Ground |
|
|
98
|
+
|
|
99
|
+
Each wiper is a three-terminal divider: terminal A and terminal B are the ends
|
|
100
|
+
of the resistor, W is the wiper. One end is tied to ground and the other to the
|
|
101
|
+
reference, so the wiper voltage is a fraction of that reference.
|
|
102
|
+
|
|
103
|
+
## Usage
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from scietex.hal.dac import MCP4251
|
|
107
|
+
|
|
108
|
+
device = MCP4251(port=0, device=1, reference=3.3) # /dev/spidev0.1
|
|
109
|
+
try:
|
|
110
|
+
code = device.channel(0).value # raw 8-bit code, 0..255
|
|
111
|
+
voltage = device.channel(0).voltage
|
|
112
|
+
print(code, voltage)
|
|
113
|
+
finally:
|
|
114
|
+
device.close()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`MCP4251(port=0, device=1, reference=3.3)` is also a context manager; leaving the
|
|
118
|
+
`with` block closes the transport:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
with MCP4251(port=0, device=1, reference=3.3) as device:
|
|
122
|
+
print(device.read_all())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Constructor keywords:
|
|
126
|
+
|
|
127
|
+
| Keyword | Meaning |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `r_ab` | Total resistance in ohms; 5k, 10k, 50k, or 100k (default 10k) |
|
|
130
|
+
| `channels` | Channels to expose, 1..2; omitted uses both |
|
|
131
|
+
| `reference` | Full-scale voltage in volts, or omitted while unknown |
|
|
132
|
+
| `grounded` | Terminal tied to ground, `"B"` (default) or `"A"` |
|
|
133
|
+
| `transport` (alias `spi`) | Any object with `transfer(tx)` and `close()` |
|
|
134
|
+
| `port`, `device` | spidev bus and chip select |
|
|
135
|
+
| `clock_pin`, `mosi_pin`, `miso_pin`, `select_pin` | gpiozero pin wiring |
|
|
136
|
+
| `label`, `log_level` | Logger name and level |
|
|
137
|
+
|
|
138
|
+
`invert` and `max_value` are not part of the constructor: the wiper is always
|
|
139
|
+
built uninverted, and the code range is fixed by the part. Passing either
|
|
140
|
+
keyword raises `TypeError`.
|
|
141
|
+
|
|
142
|
+
## Codes and outputs
|
|
143
|
+
|
|
144
|
+
`channel.value` is the raw code the wiper register holds. It is a live read:
|
|
145
|
+
every access is a new bus transaction, nothing is cached. The range is
|
|
146
|
+
`0..max_code`, which is `127` on a 7-bit part and `255` on an 8-bit part.
|
|
147
|
+
|
|
148
|
+
`channel.output` scales that code by the reference (grounded B shown here; see
|
|
149
|
+
[Grounding](#grounding)):
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
output = code / max_code * reference
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
With no reference configured, `output` is `None` — the library will not guess a
|
|
156
|
+
reference. Setting `device.reference = 3.3` later starts scaling new readings;
|
|
157
|
+
the codes themselves never change. `device.v_ref` and `channel.v_ref` are
|
|
158
|
+
aliases.
|
|
159
|
+
|
|
160
|
+
The setter clamps instead of rejecting: an output below zero becomes the bottom
|
|
161
|
+
of the scale (code 0 when grounded B) and an output above the reference becomes
|
|
162
|
+
the top code. Setting `output` while no reference is configured raises
|
|
163
|
+
`DACReferenceError`.
|
|
164
|
+
|
|
165
|
+
## Grounding
|
|
166
|
+
|
|
167
|
+
`grounded="B"` makes the output rise with the code: code 0 is 0 V, the top code
|
|
168
|
+
is the reference. `grounded="A"` mirrors the curve: code 0 is the reference and
|
|
169
|
+
the top code is 0 V. The raw `value` is the same under either wiring; only the
|
|
170
|
+
scaled `output` changes direction.
|
|
171
|
+
|
|
172
|
+
## Geometry
|
|
173
|
+
|
|
174
|
+
| Part | Channels | Resolution | Top code |
|
|
175
|
+
| --- | --- | --- | --- |
|
|
176
|
+
| MCP4231 | 2 | 7 | 127 |
|
|
177
|
+
| MCP4241 | 2 | 7 | 127 |
|
|
178
|
+
| MCP4251 | 2 | 8 | 255 |
|
|
179
|
+
| MCP4261 | 2 | 8 | 255 |
|
|
180
|
+
|
|
181
|
+
`device.channels` is a tuple in index order and `channel(n)` raises `IndexError`
|
|
182
|
+
outside the range. A part built with `channels=1` exposes channel 0 only.
|
|
183
|
+
|
|
184
|
+
## Reading and writing
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
codes = device.read_all() # one code per channel
|
|
188
|
+
written = device.write_all((0, 128))
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Both return tuples in index order. Every element is a separate bus transaction —
|
|
192
|
+
the calls read and write the device, they do not replay a cached buffer.
|
|
193
|
+
|
|
194
|
+
A single write is two transactions: the wiper command followed by a register
|
|
195
|
+
read-back, so the value a channel reports afterwards is what the device
|
|
196
|
+
returned, not what the caller submitted. `write_all` validates the whole
|
|
197
|
+
sequence before the first transaction, so a rejected code cannot leave the
|
|
198
|
+
device partially updated.
|
|
199
|
+
|
|
200
|
+
## Errors
|
|
201
|
+
|
|
202
|
+
- `TransportError` and `TransportClosed` (from `scietex.hal.spi`) mean the bus
|
|
203
|
+
transaction failed or the transport was already closed.
|
|
204
|
+
- `DACCodeError` (a `ValueError`) means a raw code is outside `0..max_code`.
|
|
205
|
+
- `DACReferenceError` (a `ValueError`) means an output was requested while no
|
|
206
|
+
reference is configured.
|
|
207
|
+
- A malformed device answer is rejected by the wrapped potentiometer with a
|
|
208
|
+
`ValueError` naming the frame.
|
|
209
|
+
|
|
210
|
+
## Testing
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
pytest # unit tests, no hardware needed
|
|
214
|
+
RUN_HARDWARE_TESTS=1 pytest tests/test_hardware.py
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The unit tests drive a scripted transport and never open a real bus. The
|
|
218
|
+
hardware test needs an MCP4251 on `/dev/spidev0.1` and is skipped unless
|
|
219
|
+
`RUN_HARDWARE_TESTS=1` is set; CI never enables it.
|
|
220
|
+
|
|
221
|
+
Other checks:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
tox -e format,lint,type
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
This project is licensed under the **MIT License** - see the LICENSE file for
|
|
230
|
+
details.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
scietex/hal/dac/__helpers.py,sha256=Z8fNY1ObX66LUjXAIoPwS5gWR4nJ5wfjUS5mMxxU6BU,7084
|
|
2
|
+
scietex/hal/dac/__init__.py,sha256=LFGg3vTgvBdPz4aHQkIkpGzPVhMnS16P2iPmYcRKWo4,588
|
|
3
|
+
scietex/hal/dac/__logger.py,sha256=nti4Iqn7yCDZrarjCE7wDKSC17fZkNnsNYEsNLObrGo,3426
|
|
4
|
+
scietex/hal/dac/channel.py,sha256=bO3kdtYQPHE36wdR7zK9BrJEzLzIXaLyEYrRWS74XKU,10049
|
|
5
|
+
scietex/hal/dac/dac.py,sha256=I_kiSHy4N_aUW-EfWtkJCXmSvQX3z77Xed2iLixGcPE,9137
|
|
6
|
+
scietex/hal/dac/errors.py,sha256=vG3I3L8lroqCEKseYFt6U4Qg2dSHSJmD9Atqj-haWcI,359
|
|
7
|
+
scietex/hal/dac/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
scietex/hal/dac/version.py,sha256=-6LRwSS3coyMIQkeofZjGTMXkVG7dm2gwquyq8O_vF0,154
|
|
9
|
+
scietex/hal/dac/mcp4xxx/__init__.py,sha256=IVCUkNOHMiLjzWQvsMRpAKU-wRXn-N-Z_wzukJrXFPU,151
|
|
10
|
+
scietex/hal/dac/mcp4xxx/mcp4xx1.py,sha256=9ryA8ygYktW6q6HY78Rz9jVeI45nKLc8zPiPAc9_oN0,1304
|
|
11
|
+
scietex/hal/dac/mcp4xxx/mcp4xxx.py,sha256=Rcm8ErmmxMStu01aHLCPZAcvkhrsXi8XhRIWWVYjbg0,5351
|
|
12
|
+
scietex_hal_dac-0.1.0.dist-info/licenses/LICENSE,sha256=Kv8K8tNh4XWlpCNncypg7EhUWm4drfJp0zCGNsrp6So,1072
|
|
13
|
+
scietex_hal_dac-0.1.0.dist-info/METADATA,sha256=j49E88O-ihrX5kLX1vxcDZxYOd_IxCzZXjZC9cFh774,8494
|
|
14
|
+
scietex_hal_dac-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
15
|
+
scietex_hal_dac-0.1.0.dist-info/top_level.txt,sha256=AyNTe2f94XrlwxyUGMuitoEj6mqEacwNkt8nU47GjvI,8
|
|
16
|
+
scietex_hal_dac-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Anton Bondarenko
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scietex
|