riden 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.
riden/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ import serial
2
+
3
+ from .bt import BtSerial
4
+ from .enums import Mode, Protection
5
+ from .field import Field
6
+ from .riden import Riden
7
+ from .transport import Delayed, KeepAlive, Logging, Modbus, Reconnectable, Transport
8
+
9
+ HANDLER_PACKAGE = "riden.bt"
10
+
11
+ if HANDLER_PACKAGE not in serial.protocol_handler_packages:
12
+ serial.protocol_handler_packages.append(HANDLER_PACKAGE)
13
+
14
+ __all__ = [
15
+ "BtSerial",
16
+ "Delayed",
17
+ "Field",
18
+ "KeepAlive",
19
+ "Logging",
20
+ "Modbus",
21
+ "Mode",
22
+ "Protection",
23
+ "Reconnectable",
24
+ "Riden",
25
+ "Transport",
26
+ ]
riden/bt/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .bt_serial import BtSerial
2
+
3
+ __all__ = [
4
+ "BtSerial"
5
+ ]
riden/bt/bt_serial.py ADDED
@@ -0,0 +1,204 @@
1
+ """Bluetooth SPP transport shaped like a pyserial port.
2
+
3
+ BtSerial.port is always a Bluetooth MAC, just as serial.Serial.port is a
4
+ device path. URL parsing lives only in serial_urlhandler/protocol_bt.py,
5
+ which pyserial uses via serial_for_url():
6
+
7
+ serial.serial_for_url("bt://E2:F9:C6:24:71:C8?channel=1&timeout=0.5")
8
+
9
+ Direct construction uses the MAC:
10
+
11
+ BtSerial("E2:F9:C6:24:71:C8", channel=1, timeout=0.5)
12
+
13
+ The connect timeout is an internal constant (CONNECT_TIMEOUT), not part of the
14
+ serial interface. For a configured but closed object use
15
+ serial_for_url(..., do_not_open=True), or BtSerial(None) and set .port before
16
+ open().
17
+ """
18
+ import contextlib
19
+ import re
20
+ import select
21
+ import socket
22
+ import time
23
+ from typing import override
24
+
25
+ import serial
26
+
27
+ # Fixed for minimalmodbus; RFCOMM has no real baudrate.
28
+ BAUDRATE = 115200
29
+
30
+ _MAC_RE = re.compile(r"^(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$")
31
+
32
+ def parse_mac(value: str) -> str:
33
+ value = value.strip()
34
+ if not _MAC_RE.match(value):
35
+ raise ValueError(f"invalid Bluetooth MAC: {value!r}")
36
+ return value.replace("-", ":").upper()
37
+
38
+
39
+ class BtSerial(contextlib.AbstractContextManager["BtSerial"]):
40
+
41
+ def __init__(self,
42
+ port: str | None = None,
43
+ channel: int = 1,
44
+ timeout: float | None = 0.5,
45
+ connect_timeout: float | None = 10
46
+ ):
47
+ self.port = port
48
+ self.channel = channel
49
+ self.sock: socket.socket | None = None
50
+ self._buf = bytearray()
51
+ self.timeout = timeout
52
+ self.connect_timeout = connect_timeout
53
+ if port is not None:
54
+ self.open()
55
+
56
+ @property
57
+ def timeout(self) -> float | None:
58
+ return self._timeout
59
+
60
+ @timeout.setter
61
+ def timeout(self, value: float | None) -> None:
62
+ if value is not None:
63
+ try:
64
+ value + 1 # numeric check, like pyserial
65
+ except TypeError:
66
+ raise ValueError(f"Not a valid timeout: {value!r}") from None
67
+ if value < 0:
68
+ raise ValueError(f"Not a valid timeout: {value!r}")
69
+ self._timeout = value
70
+ if self.sock is not None:
71
+ self.sock.settimeout(value)
72
+
73
+ @property
74
+ def baudrate(self) -> int:
75
+ # minimalmodbus reads this to calculate the 3.5-character silent
76
+ # period; RFCOMM itself has no baudrate setting.
77
+ return BAUDRATE
78
+
79
+ @property
80
+ def is_open(self) -> bool:
81
+ return self.sock is not None
82
+
83
+ # -- pyserial-compatible lifecycle ------------------------------------
84
+ def ensure_opened(self) -> socket.socket:
85
+ sock = self.sock
86
+ if sock is None:
87
+ raise serial.SerialException("Bluetooth socket is closed")
88
+ return sock
89
+
90
+ def open(self) -> None:
91
+ if self.is_open:
92
+ raise serial.SerialException("Port is already open.")
93
+ if self.port is None:
94
+ raise serial.SerialException("Port must be configured before it can be used.")
95
+
96
+ try:
97
+ mac = parse_mac(str(self.port))
98
+ except ValueError as exc:
99
+ raise serial.SerialException(str(exc)) from exc
100
+ channel = int(self.channel)
101
+ if not 1 <= channel <= 30:
102
+ raise ValueError(f"Bluetooth channel must be 1..30, got {channel}")
103
+
104
+ sock = socket.socket(
105
+ socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM
106
+ )
107
+ try:
108
+ sock.settimeout(self.connect_timeout)
109
+ sock.connect((mac, channel))
110
+ sock.settimeout(self.timeout)
111
+ except Exception:
112
+ sock.close()
113
+ raise
114
+
115
+ self.sock = sock
116
+ self.mac = mac
117
+ self.channel = channel
118
+ self._buf.clear()
119
+
120
+ def close(self) -> None:
121
+ sock = self.sock
122
+ self.sock = None
123
+ if sock is not None:
124
+ sock.close()
125
+
126
+ def __del__(self):
127
+ with contextlib.suppress(Exception):
128
+ self.close()
129
+
130
+ @override
131
+ def __exit__(self, *_):
132
+ self.close()
133
+
134
+ # -- pyserial-like I/O ------------------------------------------------
135
+ def write(self, data: bytes) -> int:
136
+ sock = self.ensure_opened()
137
+ sock.sendall(bytes(data))
138
+ return len(data)
139
+
140
+ def read(self, size: int = 1) -> bytes:
141
+ sock = self.ensure_opened()
142
+ if size <= 0:
143
+ return b""
144
+
145
+ if self.timeout is None:
146
+ # pyserial semantics: None means blocking until size bytes or EOF.
147
+ while len(self._buf) < size:
148
+ chunk = sock.recv(size - len(self._buf))
149
+ if not chunk:
150
+ raise serial.SerialException("Bluetooth peer closed the link")
151
+ self._buf.extend(chunk)
152
+ out = bytes(self._buf[:size])
153
+ del self._buf[:size]
154
+ return out
155
+
156
+ if self.timeout == 0:
157
+ # pyserial semantics: return immediately with what is available.
158
+ try:
159
+ chunk = sock.recv(size - len(self._buf), socket.MSG_DONTWAIT)
160
+ if chunk:
161
+ self._buf.extend(chunk)
162
+ except BlockingIOError:
163
+ pass
164
+ except OSError as exc:
165
+ raise serial.SerialException(str(exc)) from exc
166
+ out = bytes(self._buf[:size])
167
+ del self._buf[:size]
168
+ return out
169
+
170
+ # timeout > 0: the timeout is for the whole call (pyserial semantics).
171
+ # select() waits for readability with the time left, so the socket
172
+ # timeout is never touched and recv() cannot block past the deadline.
173
+ deadline = time.monotonic() + self.timeout
174
+ while len(self._buf) < size:
175
+ remaining = deadline - time.monotonic()
176
+ if remaining <= 0:
177
+ break
178
+ if not select.select([sock], [], [], remaining)[0]:
179
+ break
180
+ chunk = sock.recv(size - len(self._buf))
181
+ if not chunk:
182
+ raise serial.SerialException("Bluetooth peer closed the link")
183
+ self._buf.extend(chunk)
184
+
185
+ out = bytes(self._buf[:size])
186
+ del self._buf[:size]
187
+ return out
188
+
189
+ def reset_input_buffer(self) -> None:
190
+ sock = self.ensure_opened()
191
+ self._buf.clear()
192
+ # Zero-timeout poll: recv() after a readable select() returns at once,
193
+ # so the socket timeout is never touched.
194
+ while select.select([sock], [], [], 0)[0]:
195
+ if not sock.recv(4096):
196
+ break
197
+
198
+ def reset_output_buffer(self) -> None:
199
+ pass
200
+
201
+ def flush(self) -> None:
202
+ pass
203
+
204
+
@@ -0,0 +1,48 @@
1
+
2
+ import typing
3
+ from functools import partial
4
+ from urllib.parse import parse_qs, urlsplit
5
+
6
+ from .bt_serial import BtSerial
7
+
8
+
9
+ def parse_bt_url(url: str):
10
+ parsed = urlsplit(url)
11
+ if parsed.scheme.lower() != "bt":
12
+ raise ValueError(f"not a bt:// URL: {url!r}")
13
+
14
+ mac = parsed.netloc
15
+ if not mac:
16
+ raise ValueError(f"no MAC in bt URL: {url!r}")
17
+ if parsed.path not in ("", "/"):
18
+ raise ValueError(
19
+ "channel must be a query parameter, e.g. bt://MAC?channel=1"
20
+ )
21
+
22
+ query = parse_qs(parsed.query)
23
+
24
+ def qs(name: str, type: type):
25
+ values = query.get(name)
26
+ if values is None:
27
+ return None
28
+ try:
29
+ return type(values[0])
30
+ except (TypeError, ValueError) as exc:
31
+ raise ValueError(f"invalid {name} in URL: {values[0]!r}") from exc
32
+
33
+ kwargs: dict[str, typing.Any] = {
34
+ "channel": qs("channel", int) or 1,
35
+ "timeout": qs("timeout", float),
36
+ "connect_timeout": qs("connect_timeout", float)
37
+ }
38
+
39
+ return mac, kwargs
40
+
41
+ def serial_class_for_url(url: str):
42
+ """Return (MAC, partial(BtSerial, channel=..., timeout=...)).
43
+
44
+ pyserial calls this hook, then constructs the class with port=None, sets
45
+ instance.port to the returned MAC, and calls open() unless do_not_open.
46
+ """
47
+ mac, kwargs = parse_bt_url(url)
48
+ return mac, partial(BtSerial, **kwargs)
riden/enums.py ADDED
@@ -0,0 +1,11 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class Protection(IntEnum):
5
+ NONE = 0
6
+ OVP = 1
7
+ OCP = 2
8
+
9
+ class Mode(IntEnum):
10
+ CV = 0
11
+ CC = 1
riden/field.py ADDED
@@ -0,0 +1,204 @@
1
+ from collections.abc import Callable, Sequence
2
+ from enum import Enum
3
+ from functools import partial
4
+ from typing import Any, override
5
+
6
+ from .enums import Mode, Protection
7
+ from .model import Model
8
+
9
+ type RegRead = Callable[[tuple[int, ...], Model], Any]
10
+ type RegWrite = Callable[[Any, Model], tuple[int, ...]]
11
+
12
+ def compose(*funcs):
13
+ def composed_fn(arg: Any, *others):
14
+ for fn in funcs:
15
+ arg = fn(arg, *others)
16
+ return arg
17
+ return composed_fn
18
+
19
+ def read_flag(r: tuple[int, ...], _: Model):
20
+ return bool(r[0])
21
+
22
+ def read_int(r: tuple[int, ...], _: Model):
23
+ return r[0]
24
+
25
+ def write_int(v, _: Model) -> tuple[int, ...]:
26
+ return (int(v),)
27
+
28
+ def read_protection(r: tuple[int, ...], _: Model):
29
+ return Protection(r[0])
30
+
31
+ def read_mode(r: tuple[int, ...], _: Model):
32
+ return Mode(r[0])
33
+
34
+ def read_div(d, r: tuple[int, ...], _: Model):
35
+ return float(r[0] / d)
36
+
37
+ def div(d, x, _: Model):
38
+ return x / d
39
+
40
+ def read_dw(r: tuple[int, ...], _: Model):
41
+ return r[0] << 16 | r[1]
42
+
43
+ def read_temp(r: tuple[int, ...], _: Model):
44
+ return (-1 if r[0] else 1) * r[1]
45
+
46
+ def read_v(r: tuple[int, ...], m: Model):
47
+ return r[0] / m.v_scale
48
+
49
+ def write_v(v, m: Model, off = 0) -> tuple[int, ...]:
50
+ return (m.v_clamp(v, off),)
51
+
52
+ def read_i(r: tuple[int, ...], m: Model):
53
+ return r[0] / m.i_scale
54
+
55
+ def write_i(v, m: Model, off = 0) -> tuple[int, ...]:
56
+ return (m.i_clamp(v, off),)
57
+
58
+ class Field(Enum):
59
+ ID = 0, 1, read_int, None
60
+ SN = 1, 2, read_dw, None
61
+ FW = 3, 1, partial(read_div, 100), None
62
+ TEMP_C = 4, 2, read_temp, None
63
+ TEMP_F = 6, 2, read_temp, None
64
+ V_SET = 8, 1, read_v, write_v
65
+ I_SET = 9, 1, read_i, write_i
66
+ V = 10, 1, read_v, None
67
+ I = 11, 1, read_i, None
68
+ P = 12, 2, compose(read_dw, partial(div, 100)), None
69
+ V_IN = 14, 1, partial(read_div, 100), None
70
+ LOCK = 15, 1, read_flag, write_int
71
+ PROTECTION = 16, 1, read_protection, None
72
+ MODE = 17, 1, read_mode, None
73
+ EN = 18, 1, read_flag, write_int
74
+ PRESET = 19, 1, read_int, write_int
75
+
76
+ BAT_EN = 32, 1, read_flag, None
77
+ BAT_V = 33, 1, read_v, None
78
+ EXT_TEMP_C = 34, 2, read_temp, None
79
+ EXT_TEMP_F = 36, 2, read_temp, None
80
+ AH = 38, 2, compose(read_dw, partial(div, 1000)), None
81
+ WH = 40, 2, compose(read_dw, partial(div, 1000)), None
82
+
83
+ YEAR = 48, 1, read_int, write_int
84
+ MONTH = 49, 1, read_int, write_int
85
+ DAY = 50, 1, read_int, write_int
86
+ HOUR = 51, 1, read_int, write_int
87
+ MINUTE = 52, 1, read_int, write_int
88
+ SECOND = 53, 1, read_int, write_int
89
+
90
+ # Register 54 is a command register, not calibration data: writing 5377
91
+ # (0x1501) commits the calibration registers 55-62 to NVRAM. It is not
92
+ # readable because it carries no meaningful state.
93
+ CAL_COMMIT = 54, 1, None, write_int
94
+ CAL_V_ZERO = 55, 1, read_int, write_int
95
+ CAL_V_SCALE = 56, 1, read_int, write_int
96
+ CAL_BV_ZERO = 57, 1, read_int, write_int
97
+ CAL_BV_SCALE = 58, 1, read_int, write_int
98
+ CAL_I_ZERO = 59, 1, read_int, write_int
99
+ CAL_I_SCALE = 60, 1, read_int, write_int
100
+ CAL_BI_ZERO = 61, 1, read_int, write_int
101
+ CAL_BI_SCALE = 62, 1, read_int, write_int
102
+
103
+ TAKE_OK = 66, 1, read_flag, write_int
104
+ TAKE_OUT = 67, 1, read_flag, write_int
105
+ BOOT_EN = 68, 1, read_flag, write_int
106
+ VOLUME = 69, 1, read_flag, write_int
107
+ LOGO = 70, 1, read_flag, write_int
108
+ LANG = 71, 1, read_int, write_int
109
+ BACKLIGHT = 72, 1, read_int, write_int
110
+
111
+ M0_V = 80, 1, read_v, write_v
112
+ M0_A = 81, 1, read_i, write_i
113
+ M0_OVP = 82, 1, read_v, partial(write_v, off=2)
114
+ M0_OCP = 83, 1, read_i, write_i
115
+
116
+ M1_V = 84, 1, read_v, write_v
117
+ M1_A = 85, 1, read_i, write_i
118
+ M1_OVP = 86, 1, read_v, partial(write_v, off=2)
119
+ M1_OCP = 87, 1, read_i, write_i
120
+
121
+ M2_V = 88, 1, read_v, write_v
122
+ M2_A = 89, 1, read_i, write_i
123
+ M2_OVP = 90, 1, read_v, partial(write_v, off=2)
124
+ M2_OCP = 91, 1, read_i, write_i
125
+
126
+ M3_V = 92, 1, read_v, write_v
127
+ M3_A = 93, 1, read_i, write_i
128
+ M3_OVP = 94, 1, read_v, partial(write_v, off=2)
129
+ M3_OCP = 95, 1, read_i, write_i
130
+
131
+ M4_V = 96, 1, read_v, write_v
132
+ M4_A = 97, 1, read_i, write_i
133
+ M4_OVP = 98, 1, read_v, partial(write_v, off=2)
134
+ M4_OCP = 99, 1, read_i, write_i
135
+
136
+ M5_V = 100, 1, read_v, write_v
137
+ M5_A = 101, 1, read_i, write_i
138
+ M5_OVP = 102, 1, read_v, partial(write_v, off=2)
139
+ M5_OCP = 103, 1, read_i, write_i
140
+
141
+ M6_V = 104, 1, read_v, write_v
142
+ M6_A = 105, 1, read_i, write_i
143
+ M6_OVP = 106, 1, read_v, partial(write_v, off=2)
144
+ M6_OCP = 107, 1, read_i, write_i
145
+
146
+ M7_V = 108, 1, read_v, write_v
147
+ M7_A = 109, 1, read_i, write_i
148
+ M7_OVP = 110, 1, read_v, partial(write_v, off=2)
149
+ M7_OCP = 111, 1, read_i, write_i
150
+
151
+ M8_V = 112, 1, read_v, write_v
152
+ M8_A = 113, 1, read_i, write_i
153
+ M8_OVP = 114, 1, read_v, partial(write_v, off=2)
154
+ M8_OCP = 115, 1, read_i, write_i
155
+
156
+ M9_V = 116, 1, read_v, write_v
157
+ M9_A = 117, 1, read_i, write_i
158
+ M9_OVP = 118, 1, read_v, partial(write_v, off=2)
159
+ M9_OCP = 119, 1, read_i, write_i
160
+
161
+ addr: int
162
+ count: int
163
+ read: RegRead | None
164
+ write: RegWrite | None
165
+
166
+ def __new__(cls, addr: int, count: int, read: RegRead | None, write: RegWrite | None):
167
+ obj = object.__new__(cls)
168
+ obj._value_ = addr
169
+ obj.addr = addr
170
+ obj.count = count
171
+ obj.read = read
172
+ obj.write = write
173
+ return obj
174
+
175
+ @override
176
+ @classmethod
177
+ def _missing_(cls, value: object) -> Field | None:
178
+ if isinstance(value, str):
179
+ return cls.__members__.get(value.upper())
180
+ return None
181
+
182
+ @property
183
+ def label(self) -> str:
184
+ return self.name.lower()
185
+
186
+ @override
187
+ def __str__(self) -> str:
188
+ return self.label
189
+
190
+ @property
191
+ def is_readable(self):
192
+ return self.read is not None
193
+
194
+ @property
195
+ def is_writable(self):
196
+ return self.write is not None
197
+
198
+
199
+ def flatten_fields(fields: Sequence[str | Field | Sequence[str | Field]]):
200
+ for field in fields:
201
+ match field:
202
+ case Field(): yield field
203
+ case str(): yield Field(field)
204
+ case _: yield from flatten_fields(field)
riden/model.py ADDED
@@ -0,0 +1,32 @@
1
+ from collections.abc import Sequence
2
+ from enum import Enum
3
+
4
+
5
+ class Model(Enum):
6
+ RD6006 = (range(60060, 60065), 100, 1000, 60, 6)
7
+ RD6006P = ([60065], 1000, 10000, 60, 6)
8
+ RK6006 = ([60066], 100, 1000, 60, 6)
9
+ RD6012 = (range(60120, 60125), 100, 100, 60, 12)
10
+ RD6012P = (range(60125, 60130), 1000, 100, 60, 12)
11
+ RD6018 = (range(60180, 60190), 100, 100, 60, 18)
12
+ RD6024 = (range(60241, 60250), 100, 100, 60, 24)
13
+
14
+ def __init__(self, codes: Sequence[int], v_scale: int, i_scale: int, v_max: int, i_max: int):
15
+ self.codes = codes
16
+ self.v_scale = v_scale
17
+ self.i_scale = i_scale
18
+ self.v_max = v_max
19
+ self.i_max = i_max
20
+
21
+ def v_clamp(self, value: float, offset: float = 0) -> int:
22
+ return round(max(0, min(self.v_max + offset, value)) * self.v_scale)
23
+
24
+ def i_clamp(self, value: float, offset: float = 0) -> int:
25
+ return round(max(0, min(self.i_max + offset, value)) * self.i_scale)
26
+
27
+ @staticmethod
28
+ def find(code: int) -> Model:
29
+ for model in Model:
30
+ if code in model.codes:
31
+ return model
32
+ return next(iter(Model))
riden/py.typed ADDED
File without changes