pymp305 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.
- pymp305/__init__.py +38 -0
- pymp305/device.py +271 -0
- pymp305/protocol.py +134 -0
- pymp305/responses.py +171 -0
- pymp305-0.1.0.dist-info/METADATA +121 -0
- pymp305-0.1.0.dist-info/RECORD +8 -0
- pymp305-0.1.0.dist-info/WHEEL +5 -0
- pymp305-0.1.0.dist-info/top_level.txt +1 -0
pymp305/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""pymp305 — Python driver for the ISDT MP305 line (MP305A / MP305B) over USB-HID.
|
|
2
|
+
|
|
3
|
+
Both models share one controller and protocol; the model is auto-detected for error
|
|
4
|
+
decoding. Reverse-engineered from the official ISDT WebLink web app; see ../PROTOCOL.md.
|
|
5
|
+
"""
|
|
6
|
+
from .device import (
|
|
7
|
+
MP305,
|
|
8
|
+
MP305A,
|
|
9
|
+
MP305B,
|
|
10
|
+
MP305Error,
|
|
11
|
+
MP305BError,
|
|
12
|
+
ControlCommand,
|
|
13
|
+
ChargeCommand,
|
|
14
|
+
SystemSetCommand,
|
|
15
|
+
)
|
|
16
|
+
from .responses import (
|
|
17
|
+
State,
|
|
18
|
+
SystemSettings,
|
|
19
|
+
HardwareInfo,
|
|
20
|
+
decode_errors,
|
|
21
|
+
BATTERY_TYPES,
|
|
22
|
+
ERROR_LIST,
|
|
23
|
+
MODEL_DC,
|
|
24
|
+
MODEL_PROGRAMMABLE,
|
|
25
|
+
MODEL_USB_PD,
|
|
26
|
+
MODEL_CHARGE,
|
|
27
|
+
)
|
|
28
|
+
from . import protocol
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"MP305", "MP305A", "MP305B", "MP305Error", "MP305BError",
|
|
32
|
+
"ControlCommand", "ChargeCommand", "SystemSetCommand",
|
|
33
|
+
"State", "SystemSettings", "HardwareInfo", "decode_errors",
|
|
34
|
+
"BATTERY_TYPES", "ERROR_LIST",
|
|
35
|
+
"MODEL_DC", "MODEL_PROGRAMMABLE", "MODEL_USB_PD", "MODEL_CHARGE",
|
|
36
|
+
"protocol",
|
|
37
|
+
]
|
|
38
|
+
__version__ = "0.1.0"
|
pymp305/device.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""High-level driver for the ISDT MP305 (MP305A / MP305B) over USB-HID.
|
|
2
|
+
|
|
3
|
+
Both models share the same controller and protocol; the only model-specific behaviour is
|
|
4
|
+
error-bit decoding, handled automatically once the device name is read.
|
|
5
|
+
|
|
6
|
+
Requires the `hid` package (cython-hidapi): pip install hidapi
|
|
7
|
+
|
|
8
|
+
Example
|
|
9
|
+
-------
|
|
10
|
+
from pymp305 import MP305
|
|
11
|
+
with MP305.open() as psu:
|
|
12
|
+
print(psu.hardware_info())
|
|
13
|
+
psu.set_output(voltage=5.0, current=1.0, on=True)
|
|
14
|
+
print(psu.read_state())
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import struct
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from . import protocol as P
|
|
23
|
+
from .responses import HardwareInfo, State, SystemSettings
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import hid # cython-hidapi
|
|
27
|
+
except ImportError: # pragma: no cover
|
|
28
|
+
hid = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MP305Error(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ControlCommand:
|
|
37
|
+
"""Fields for the 0xC8 control command (DPConnectModel)."""
|
|
38
|
+
remote_con: int = 1 # 1 = take remote control (required to change anything)
|
|
39
|
+
set_voltage: float = 0.0 # volts
|
|
40
|
+
set_current: float = 0.0 # amps
|
|
41
|
+
real_change: int = 3 # live-apply: 1=V, 2=I, 3=both
|
|
42
|
+
voltage_slow: int = 0
|
|
43
|
+
current_over: int = 0
|
|
44
|
+
output: int = 0 # 1 = output ON
|
|
45
|
+
model: int = 0 # 0 = DC PSU
|
|
46
|
+
refresh: int = 0
|
|
47
|
+
|
|
48
|
+
def payload(self) -> bytes:
|
|
49
|
+
return struct.pack(
|
|
50
|
+
"<BHHBBBBBB",
|
|
51
|
+
self.remote_con & 0xFF,
|
|
52
|
+
int(round(self.set_voltage * 100)) & 0xFFFF, # 10 mV units
|
|
53
|
+
int(round(self.set_current * 1000)) & 0xFFFF, # 1 mA units
|
|
54
|
+
self.real_change & 0xFF,
|
|
55
|
+
self.voltage_slow & 0xFF,
|
|
56
|
+
self.current_over & 0xFF,
|
|
57
|
+
self.output & 0xFF,
|
|
58
|
+
self.model & 0xFF,
|
|
59
|
+
self.refresh & 0xFF,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ChargeCommand:
|
|
65
|
+
"""Fields for the 0xEE charge command (chargeConnectCmd)."""
|
|
66
|
+
remote_con: int = 1
|
|
67
|
+
battery_type: int = 0 # index into responses.BATTERY_TYPES
|
|
68
|
+
capacity_voltage: float = 0 # per-cell V (×1000); raw cell count for NiMH/Cd
|
|
69
|
+
cells: int = 1
|
|
70
|
+
current: float = 0.0 # amps
|
|
71
|
+
output: int = 0
|
|
72
|
+
model: int = 3 # charge
|
|
73
|
+
|
|
74
|
+
def payload(self) -> bytes:
|
|
75
|
+
cap = (int(round(self.capacity_voltage * 1000))
|
|
76
|
+
if self.battery_type != 5 else int(self.capacity_voltage))
|
|
77
|
+
return struct.pack(
|
|
78
|
+
"<BBHBHBB",
|
|
79
|
+
self.remote_con & 0xFF,
|
|
80
|
+
self.battery_type & 0xFF,
|
|
81
|
+
cap & 0xFFFF,
|
|
82
|
+
self.cells & 0xFF,
|
|
83
|
+
int(round(self.current * 1000)) & 0xFFFF,
|
|
84
|
+
self.output & 0xFF,
|
|
85
|
+
self.model & 0xFF,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class SystemSetCommand:
|
|
91
|
+
"""Fields for the 0xC6 system-settings command (systemSetCmd)."""
|
|
92
|
+
per_limit: int = 90
|
|
93
|
+
volume: int = 3
|
|
94
|
+
screen_off: int = 0
|
|
95
|
+
shutdown: int = 0
|
|
96
|
+
screen_direction: int = 0
|
|
97
|
+
slope_steps: int = 0
|
|
98
|
+
current_over: int = 0
|
|
99
|
+
system_check: int = 0
|
|
100
|
+
recover: int = 0
|
|
101
|
+
usb_line: int | None = None
|
|
102
|
+
|
|
103
|
+
def payload(self) -> bytes:
|
|
104
|
+
buf = struct.pack(
|
|
105
|
+
"<BBBBBHHBB",
|
|
106
|
+
self.per_limit & 0xFF, self.volume & 0xFF, self.screen_off & 0xFF,
|
|
107
|
+
self.shutdown & 0xFF, self.screen_direction & 0xFF,
|
|
108
|
+
self.slope_steps & 0xFFFF, self.current_over & 0xFFFF,
|
|
109
|
+
self.system_check & 0xFF, self.recover & 0xFF,
|
|
110
|
+
)
|
|
111
|
+
if self.usb_line is not None:
|
|
112
|
+
buf += struct.pack("<H", self.usb_line & 0xFFFF)
|
|
113
|
+
return buf
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MP305:
|
|
117
|
+
"""Driver for an ISDT MP305A or MP305B. The concrete model is auto-detected from the
|
|
118
|
+
device name on the first `hardware_info()` call and used for error decoding."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, device, report_size: int = P.REPORT_SIZE):
|
|
121
|
+
self._dev = device
|
|
122
|
+
self._report_size = report_size
|
|
123
|
+
self.device_name: str | None = None # "MP305A" / "MP305B", set by hardware_info()
|
|
124
|
+
|
|
125
|
+
# ---- connection ------------------------------------------------------
|
|
126
|
+
@staticmethod
|
|
127
|
+
def list_devices() -> list[dict]:
|
|
128
|
+
if hid is None:
|
|
129
|
+
raise MP305Error("the 'hidapi' package is not installed (pip install hidapi)")
|
|
130
|
+
return [d for d in hid.enumerate() if d["vendor_id"] == P.VENDOR_ID]
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def open(cls, path: bytes | None = None, serial: str | None = None) -> "MP305":
|
|
134
|
+
"""Open the MP305. Picks the HID interface with usage_page 0x01 / usage 0x04
|
|
135
|
+
when several interfaces from VID 0x28E9 are present."""
|
|
136
|
+
if hid is None:
|
|
137
|
+
raise MP305Error("the 'hidapi' package is not installed (pip install hidapi)")
|
|
138
|
+
dev = hid.device()
|
|
139
|
+
if path is not None:
|
|
140
|
+
dev.open_path(path)
|
|
141
|
+
else:
|
|
142
|
+
candidates = cls.list_devices()
|
|
143
|
+
if not candidates:
|
|
144
|
+
raise MP305Error(f"no device with VID 0x{P.VENDOR_ID:04X} found")
|
|
145
|
+
preferred = [c for c in candidates
|
|
146
|
+
if c.get("usage_page") == P.HID_USAGE_PAGE
|
|
147
|
+
and c.get("usage") == P.HID_USAGE]
|
|
148
|
+
chosen = (preferred or candidates)[0]
|
|
149
|
+
dev.open_path(chosen["path"])
|
|
150
|
+
try:
|
|
151
|
+
dev.set_nonblocking(0)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return cls(dev)
|
|
155
|
+
|
|
156
|
+
def close(self):
|
|
157
|
+
self._dev.close()
|
|
158
|
+
|
|
159
|
+
def __enter__(self):
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(self, *exc):
|
|
163
|
+
self.close()
|
|
164
|
+
|
|
165
|
+
# ---- raw transport ---------------------------------------------------
|
|
166
|
+
def send(self, cmd: int, payload: bytes = b"") -> None:
|
|
167
|
+
report = P.build_report(cmd, payload, self._report_size)
|
|
168
|
+
n = self._dev.write(report)
|
|
169
|
+
if n < 0:
|
|
170
|
+
raise MP305Error("HID write failed")
|
|
171
|
+
|
|
172
|
+
def send_raw_payload(self, payload: bytes) -> None:
|
|
173
|
+
"""Send a payload whose first byte is the command byte (e.g. BOOT/REBOOT)."""
|
|
174
|
+
self.send(payload[0], payload[1:])
|
|
175
|
+
|
|
176
|
+
def read_frame(self, timeout_ms: int = 1000) -> P.Frame | None:
|
|
177
|
+
raw = self._dev.read(self._report_size + 1, timeout_ms)
|
|
178
|
+
if not raw:
|
|
179
|
+
return None
|
|
180
|
+
return P.parse_report(bytes(raw))
|
|
181
|
+
|
|
182
|
+
def request(self, cmd: int, expect: int, payload: bytes = b"",
|
|
183
|
+
timeout_ms: int = 1500) -> P.Frame:
|
|
184
|
+
"""Send `cmd` and wait until a frame with command byte `expect` arrives."""
|
|
185
|
+
self.send(cmd, payload)
|
|
186
|
+
deadline = time.monotonic() + timeout_ms / 1000.0
|
|
187
|
+
while time.monotonic() < deadline:
|
|
188
|
+
frame = self.read_frame(timeout_ms=max(1, int((deadline - time.monotonic()) * 1000)))
|
|
189
|
+
if frame is None:
|
|
190
|
+
continue
|
|
191
|
+
if frame.cmd == expect:
|
|
192
|
+
return frame
|
|
193
|
+
raise MP305Error(f"timed out waiting for response 0x{expect:02X} to 0x{cmd:02X}")
|
|
194
|
+
|
|
195
|
+
# ---- high-level reads ------------------------------------------------
|
|
196
|
+
def hardware_info(self, timeout_ms: int = 1500) -> HardwareInfo:
|
|
197
|
+
f = self.request(P.CMD_HW_INFO, P.RESP_HW_INFO, timeout_ms=timeout_ms)
|
|
198
|
+
info = HardwareInfo.parse(f.values)
|
|
199
|
+
if info.device_name:
|
|
200
|
+
self.device_name = info.device_name # cache for model-aware error decoding
|
|
201
|
+
return info
|
|
202
|
+
|
|
203
|
+
def read_state(self, realtime: bool = True, timeout_ms: int = 1500) -> State:
|
|
204
|
+
"""Read the live measurement/state frame (0xC3)."""
|
|
205
|
+
cmd = P.CMD_REALTIME if realtime else P.CMD_STATE_INFO
|
|
206
|
+
f = self.request(cmd, P.RESP_STATE, timeout_ms=timeout_ms)
|
|
207
|
+
return State.parse(f.values, device_name=self.device_name)
|
|
208
|
+
|
|
209
|
+
def read_system_settings(self, timeout_ms: int = 1500) -> SystemSettings:
|
|
210
|
+
f = self.request(P.CMD_SYS_GET, P.RESP_SYS, timeout_ms=timeout_ms)
|
|
211
|
+
return SystemSettings.parse(f.values)
|
|
212
|
+
|
|
213
|
+
# ---- high-level control ---------------------------------------------
|
|
214
|
+
def control(self, cmd: ControlCommand, timeout_ms: int = 1500) -> P.Frame:
|
|
215
|
+
return self.request(P.CMD_CONTROL, P.RESP_CONTROL, cmd.payload(), timeout_ms)
|
|
216
|
+
|
|
217
|
+
def set_output(self, voltage: float | None = None, current: float | None = None,
|
|
218
|
+
on: bool | None = None, *, model: int = 0,
|
|
219
|
+
real_change: int = 3, timeout_ms: int = 1500) -> State:
|
|
220
|
+
"""Convenience: take remote control and set V / I / output in one call.
|
|
221
|
+
|
|
222
|
+
Unspecified values are read from the current state so they are preserved.
|
|
223
|
+
Returns the fresh state after the change.
|
|
224
|
+
"""
|
|
225
|
+
st = self.read_state(timeout_ms=timeout_ms)
|
|
226
|
+
cmd = ControlCommand(
|
|
227
|
+
remote_con=1,
|
|
228
|
+
set_voltage=st.set_voltage if voltage is None else voltage,
|
|
229
|
+
set_current=st.set_current if current is None else current,
|
|
230
|
+
real_change=real_change,
|
|
231
|
+
voltage_slow=st.voltage_slow,
|
|
232
|
+
current_over=st.current_over,
|
|
233
|
+
output=st.output if on is None else (1 if on else 0),
|
|
234
|
+
model=model,
|
|
235
|
+
refresh=0,
|
|
236
|
+
)
|
|
237
|
+
self.control(cmd, timeout_ms=timeout_ms)
|
|
238
|
+
return self.read_state(timeout_ms=timeout_ms)
|
|
239
|
+
|
|
240
|
+
def output_on(self, **kw) -> State:
|
|
241
|
+
return self.set_output(on=True, **kw)
|
|
242
|
+
|
|
243
|
+
def output_off(self, **kw) -> State:
|
|
244
|
+
return self.set_output(on=False, **kw)
|
|
245
|
+
|
|
246
|
+
def release_remote(self, timeout_ms: int = 1500) -> P.Frame:
|
|
247
|
+
"""Hand control back to the device's front panel (remoteCon = 0)."""
|
|
248
|
+
return self.control(ControlCommand(remote_con=0), timeout_ms=timeout_ms)
|
|
249
|
+
|
|
250
|
+
def set_system_settings(self, cmd: SystemSetCommand, timeout_ms: int = 1500) -> P.Frame:
|
|
251
|
+
return self.request(P.CMD_SYS_SET, P.RESP_SYS_SET, cmd.payload(), timeout_ms)
|
|
252
|
+
|
|
253
|
+
def charge(self, cmd: ChargeCommand, timeout_ms: int = 1500) -> P.Frame:
|
|
254
|
+
return self.request(P.CMD_CHARGE_CONTROL, P.RESP_CHARGE, cmd.payload(), timeout_ms)
|
|
255
|
+
|
|
256
|
+
def set_language(self, index: int, timeout_ms: int = 1500) -> P.Frame:
|
|
257
|
+
return self.request(P.CMD_SET_LANGUAGE, 0xA3, bytes([index & 0xFF]), timeout_ms)
|
|
258
|
+
|
|
259
|
+
# ---- danger zone -----------------------------------------------------
|
|
260
|
+
def reboot(self) -> None:
|
|
261
|
+
self.send_raw_payload(P.REBOOT_PAYLOAD)
|
|
262
|
+
|
|
263
|
+
def enter_bootloader(self) -> None:
|
|
264
|
+
self.send_raw_payload(P.BOOT_PAYLOAD)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# Both models share this driver; aliases for discoverability / explicit intent.
|
|
268
|
+
MP305A = MP305
|
|
269
|
+
MP305B = MP305
|
|
270
|
+
# Backwards-compatible error alias.
|
|
271
|
+
MP305BError = MP305Error
|
pymp305/protocol.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Wire-protocol framing for the ISDT MP305 (MP305A / MP305B) over USB-HID.
|
|
2
|
+
|
|
3
|
+
Transcribed from the official WebLink web app (see ../../PROTOCOL.md). All framing
|
|
4
|
+
quirks (length byte, 0xAA stuffing, checksum) are reproduced exactly so the
|
|
5
|
+
checksums match what the firmware expects.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
# USB-HID identity (from HIDDeviceManager.connect)
|
|
12
|
+
VENDOR_ID = 0x28E9 # GigaDevice
|
|
13
|
+
HID_USAGE_PAGE = 0x01
|
|
14
|
+
HID_USAGE = 0x04
|
|
15
|
+
REPORT_ID = 0x01
|
|
16
|
+
REPORT_SIZE = 64 # output report payload size (zero padded)
|
|
17
|
+
|
|
18
|
+
FRAME_START = 0xAA
|
|
19
|
+
GROUP_ID = 0x12
|
|
20
|
+
|
|
21
|
+
# ---- command bytes -------------------------------------------------------
|
|
22
|
+
CMD_HW_INFO = 0xE0 # -> 0xE1
|
|
23
|
+
CMD_STATE_INFO = 0xC2 # -> 0xC3
|
|
24
|
+
CMD_REALTIME = 0xBD # -> 0xC3
|
|
25
|
+
CMD_SYS_GET = 0xC4 # -> 0xC5
|
|
26
|
+
CMD_SYS_SET = 0xC6 # -> 0xC7
|
|
27
|
+
CMD_CONTROL = 0xC8 # -> 0xC9
|
|
28
|
+
CMD_CHARGE_INFO = 0xEC # -> 0xED
|
|
29
|
+
CMD_CHARGE_SEARCH = 0xEA # -> 0xEB
|
|
30
|
+
CMD_CHARGE_CONTROL = 0xEE # -> 0xEF
|
|
31
|
+
CMD_SET_LANGUAGE = 0xA2 # -> 0xA3
|
|
32
|
+
|
|
33
|
+
# response command bytes
|
|
34
|
+
RESP_HW_INFO = 0xE1
|
|
35
|
+
RESP_STATE = 0xC3
|
|
36
|
+
RESP_SYS = 0xC5
|
|
37
|
+
RESP_SYS_SET = 0xC7
|
|
38
|
+
RESP_CONTROL = 0xC9
|
|
39
|
+
RESP_CHARGE = 0xED
|
|
40
|
+
|
|
41
|
+
# raw multi-byte payloads for boot/reboot (cmd byte + extra data bytes)
|
|
42
|
+
BOOT_PAYLOAD = bytes([0xF0, 0xAC]) # jump to bootloader
|
|
43
|
+
REBOOT_PAYLOAD = bytes([0xFC, 0xCA]) # reboot device
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _add_0xAA(data: list[int]) -> list[int]:
|
|
47
|
+
"""Double every 0xAA in the DATA region (index > 5), matching Cmd.add0xAA."""
|
|
48
|
+
out: list[int] = []
|
|
49
|
+
for i, b in enumerate(data):
|
|
50
|
+
out.append(b)
|
|
51
|
+
if b == FRAME_START and i > 5:
|
|
52
|
+
out.append(FRAME_START)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _process_hex_array(arr: list[int]) -> list[int]:
|
|
57
|
+
"""Set the length byte [0] and checksum byte [last]; matches Cmd.processHexArray."""
|
|
58
|
+
a = list(arr)
|
|
59
|
+
a[0] = (len(a) - 1) & 0xFF
|
|
60
|
+
|
|
61
|
+
total = 0
|
|
62
|
+
prev_is_aa = False
|
|
63
|
+
for i in range(2, len(a) - 1): # index 2 .. second-to-last
|
|
64
|
+
v = a[i]
|
|
65
|
+
cur_is_aa = v == FRAME_START
|
|
66
|
+
if cur_is_aa and prev_is_aa: # consecutive 0xAA counted once
|
|
67
|
+
prev_is_aa = False
|
|
68
|
+
continue
|
|
69
|
+
total += v
|
|
70
|
+
prev_is_aa = cur_is_aa
|
|
71
|
+
a[-1] = total & 0xFF
|
|
72
|
+
|
|
73
|
+
if a[-1] == FRAME_START: # avoid a checksum that looks like a marker
|
|
74
|
+
a.append(FRAME_START)
|
|
75
|
+
a[0] = (a[0] + 1) & 0xFF
|
|
76
|
+
return a
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_frame(cmd: int, payload: bytes = b"") -> bytes:
|
|
80
|
+
"""Build a complete HID frame (without the report-ID byte) for `cmd`+`payload`."""
|
|
81
|
+
body = [0x00, FRAME_START, GROUP_ID, (1 + len(payload)) & 0xFF, cmd, *payload, 0x00]
|
|
82
|
+
body = _add_0xAA(body)
|
|
83
|
+
return bytes(_process_hex_array(body))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_report(cmd: int, payload: bytes = b"", report_size: int = REPORT_SIZE) -> bytes:
|
|
87
|
+
"""Build the full bytes to hand to a hidapi `write()`: report-id + frame + padding."""
|
|
88
|
+
frame = build_frame(cmd, payload)
|
|
89
|
+
buf = bytes([REPORT_ID]) + frame
|
|
90
|
+
if report_size and len(buf) < report_size + 1:
|
|
91
|
+
buf += b"\x00" * (report_size + 1 - len(buf))
|
|
92
|
+
return buf
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Frame:
|
|
97
|
+
cmd: int
|
|
98
|
+
payload: bytes # bytes after the cmd byte, de-stuffed
|
|
99
|
+
values: bytes # full de-stuffed frame: [N, 0xAA, 0x12, L, cmd, ...]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_report(raw: bytes) -> Frame | None:
|
|
103
|
+
"""De-stuff and parse a raw HID input report (may or may not include the report-ID).
|
|
104
|
+
|
|
105
|
+
Returns None if no valid [0xAA, 0x12] header is found.
|
|
106
|
+
"""
|
|
107
|
+
buf = bytes(raw)
|
|
108
|
+
# Locate the 0xAA,0x12 header; the byte before it is the length byte N.
|
|
109
|
+
start = -1
|
|
110
|
+
for i in range(len(buf) - 1):
|
|
111
|
+
if buf[i] == FRAME_START and buf[i + 1] == GROUP_ID:
|
|
112
|
+
start = i - 1
|
|
113
|
+
break
|
|
114
|
+
if start < 0:
|
|
115
|
+
return None
|
|
116
|
+
frame = list(buf[start:])
|
|
117
|
+
|
|
118
|
+
# De-stuff consecutive 0xAA (keep first), decrement N for each dropped byte.
|
|
119
|
+
values: list[int] = []
|
|
120
|
+
prev_is_aa = False
|
|
121
|
+
for b in frame:
|
|
122
|
+
cur_is_aa = b == FRAME_START
|
|
123
|
+
if cur_is_aa and prev_is_aa:
|
|
124
|
+
if values:
|
|
125
|
+
values[0] = (values[0] - 1) & 0xFF
|
|
126
|
+
else:
|
|
127
|
+
values.append(b)
|
|
128
|
+
prev_is_aa = cur_is_aa
|
|
129
|
+
|
|
130
|
+
if len(values) < 6:
|
|
131
|
+
return None
|
|
132
|
+
cmd = values[4]
|
|
133
|
+
payload = bytes(values[5:-1]) # exclude the trailing checksum byte
|
|
134
|
+
return Frame(cmd=cmd, payload=payload, values=bytes(values))
|
pymp305/responses.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Parsers for ISDT MP305 (MP305A / MP305B) response frames. Offsets/units transcribed
|
|
2
|
+
from the WebLink source (DP3005Resp.js, dpstateResp.js, hardwareInfoResp.js, constant.js)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
# bit-index -> error name, from constant.js `errorLists`
|
|
8
|
+
ERROR_LIST = [
|
|
9
|
+
"errorOutRev", "errorBattVolt", "errorBattTemp_L", "errorBattTemp_H",
|
|
10
|
+
"errorBoardTemp_H", "errorDcOutOCP", "errorDcOutOVP", "errorDICInitFail",
|
|
11
|
+
"errorDcOutVol", "errorTimeOut", "errorConnectionBroken", "errorBatteryOver",
|
|
12
|
+
"errorBatteryLow", "errorCellsNode", "errorNoBattery", "errorCapacity",
|
|
13
|
+
"errorUnknown",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
# control/model enums
|
|
17
|
+
MODEL_DC = 0 # plain DC power supply (CV/CC)
|
|
18
|
+
MODEL_PROGRAMMABLE = 1
|
|
19
|
+
MODEL_USB_PD = 2
|
|
20
|
+
MODEL_CHARGE = 3
|
|
21
|
+
|
|
22
|
+
BATTERY_TYPES = ["LiHv", "LiPo", "Lilon", "LiFe", "Pb", "NiMH/Cd"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _u8(b, i): return b[i]
|
|
26
|
+
def _u16(b, i): return b[i] | (b[i + 1] << 8)
|
|
27
|
+
def _u32(b, i): return b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Non-MP305B devices (e.g. MP305A) remap a few low error bits — from constant.js getByteType.
|
|
31
|
+
SPECIAL_ERRORS = {1: "errorUnknown", 2: "errorUnknown", 3: "errorBattTemp_H_A"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_errors(mask: int, device_name: str | None = None,
|
|
35
|
+
charge_mode: bool = False) -> list[str]:
|
|
36
|
+
"""Decode a charge-error bitmask to error names, MP305A/MP305B-aware.
|
|
37
|
+
|
|
38
|
+
Mirrors WebLink's `getByteType`: the meaningful bit-width is 17 in charge mode
|
|
39
|
+
(model 3) and 9 otherwise; MP305B maps bits straight to `ERROR_LIST` while other
|
|
40
|
+
models remap bits 1-3 via `SPECIAL_ERRORS`.
|
|
41
|
+
"""
|
|
42
|
+
if not mask:
|
|
43
|
+
return []
|
|
44
|
+
width = 17 if charge_mode else 9
|
|
45
|
+
names: list[str] = []
|
|
46
|
+
for i in range(min(width, len(ERROR_LIST))):
|
|
47
|
+
if not (mask & (1 << i)):
|
|
48
|
+
continue
|
|
49
|
+
name = ERROR_LIST[i] if device_name == "MP305B" else (SPECIAL_ERRORS.get(i) or ERROR_LIST[i])
|
|
50
|
+
if name:
|
|
51
|
+
names.append(name)
|
|
52
|
+
return names
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class State:
|
|
57
|
+
"""Decoded 0xC3 state frame (DP3005Resp), with physical units applied."""
|
|
58
|
+
out_state: int = 0
|
|
59
|
+
battery_state: int = 0
|
|
60
|
+
percentage: int = 0
|
|
61
|
+
voltage: float = 0.0 # V (measured output)
|
|
62
|
+
set_voltage: float = 0.0 # V
|
|
63
|
+
current: float = 0.0 # A (measured output)
|
|
64
|
+
set_current: float = 0.0 # A
|
|
65
|
+
working_time: int = 0 # s
|
|
66
|
+
energy: float = 0.0 # Wh
|
|
67
|
+
power: float = 0.0 # W
|
|
68
|
+
current_over: int = 0
|
|
69
|
+
real_change: int = 0
|
|
70
|
+
voltage_slow: int = 0
|
|
71
|
+
output: int = 0 # 1 = output on
|
|
72
|
+
model: int = 0
|
|
73
|
+
voltage_board: int = 0
|
|
74
|
+
current_board: int = 0
|
|
75
|
+
temperature: int = 0 # °C
|
|
76
|
+
charge_error: int = 0
|
|
77
|
+
errors: list[str] = field(default_factory=list)
|
|
78
|
+
wave_pause: int = 0
|
|
79
|
+
wave_time: int = 0
|
|
80
|
+
raw: bytes = b""
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def parse(cls, frame_values: bytes, index: int = 5,
|
|
84
|
+
device_name: str | None = None) -> "State":
|
|
85
|
+
v = frame_values
|
|
86
|
+
i = index
|
|
87
|
+
s = cls(raw=bytes(v))
|
|
88
|
+
s.out_state = _u8(v, i); i += 1
|
|
89
|
+
s.battery_state = _u8(v, i); i += 1
|
|
90
|
+
s.percentage = _u8(v, i); i += 1
|
|
91
|
+
s.voltage = _u16(v, i) / 100.0; i += 2
|
|
92
|
+
s.set_voltage = _u16(v, i) / 100.0; i += 2
|
|
93
|
+
s.current = _u16(v, i) / 1000.0; i += 2
|
|
94
|
+
s.set_current = _u16(v, i) / 1000.0; i += 2
|
|
95
|
+
s.working_time = _u32(v, i); i += 4
|
|
96
|
+
s.energy = _u32(v, i) / 1000.0; i += 4
|
|
97
|
+
s.power = _u16(v, i) / 100.0; i += 2
|
|
98
|
+
s.current_over = _u8(v, i); i += 1
|
|
99
|
+
s.real_change = _u8(v, i); i += 1
|
|
100
|
+
s.voltage_slow = _u8(v, i); i += 1
|
|
101
|
+
s.output = _u8(v, i); i += 1
|
|
102
|
+
s.model = _u8(v, i); i += 1
|
|
103
|
+
s.voltage_board = _u8(v, i); i += 1
|
|
104
|
+
s.current_board = _u8(v, i); i += 1
|
|
105
|
+
s.temperature = _u8(v, i); i += 1
|
|
106
|
+
# optional trailing fields, gated on the length byte (values[0]) like the JS
|
|
107
|
+
if v[0] > 34 and i + 1 < len(v):
|
|
108
|
+
s.charge_error = _u16(v, i); i += 2
|
|
109
|
+
s.errors = decode_errors(s.charge_error, device_name,
|
|
110
|
+
charge_mode=(s.model == MODEL_CHARGE))
|
|
111
|
+
if v[0] > 36 and i + 4 < len(v):
|
|
112
|
+
s.wave_pause = _u8(v, i); i += 1
|
|
113
|
+
s.wave_time = _u32(v, i); i += 4
|
|
114
|
+
return s
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class SystemSettings:
|
|
119
|
+
"""Decoded 0xC5 frame (DPStateResp)."""
|
|
120
|
+
per_limit: int = 0
|
|
121
|
+
volume: int = 0
|
|
122
|
+
screen_off: int = 0
|
|
123
|
+
shutdown: int = 0
|
|
124
|
+
screen_direction: int = 0
|
|
125
|
+
slope_steps: int = 0
|
|
126
|
+
current_over: int = 0
|
|
127
|
+
usb_line: int | None = None
|
|
128
|
+
raw: bytes = b""
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def parse(cls, frame_values: bytes, index: int = 5) -> "SystemSettings":
|
|
132
|
+
v = frame_values
|
|
133
|
+
i = index
|
|
134
|
+
s = cls(raw=bytes(v))
|
|
135
|
+
s.per_limit = _u8(v, i); i += 1
|
|
136
|
+
s.volume = _u8(v, i); i += 1
|
|
137
|
+
s.screen_off = _u8(v, i); i += 1
|
|
138
|
+
s.shutdown = _u8(v, i); i += 1
|
|
139
|
+
s.screen_direction = _u8(v, i); i += 1
|
|
140
|
+
s.slope_steps = _u16(v, i); i += 2
|
|
141
|
+
s.current_over = _u16(v, i); i += 2
|
|
142
|
+
if v[0] > 14 and i + 1 < len(v):
|
|
143
|
+
s.usb_line = _u16(v, i); i += 2
|
|
144
|
+
return s
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class HardwareInfo:
|
|
149
|
+
"""Decoded 0xE1 frame (HardwareInfoResp, HID layout: index=5)."""
|
|
150
|
+
device_id: list[int] = field(default_factory=list)
|
|
151
|
+
hardware_version: str = ""
|
|
152
|
+
boot_version: str = ""
|
|
153
|
+
app_version: str = ""
|
|
154
|
+
device_name: str = ""
|
|
155
|
+
raw: bytes = b""
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def parse(cls, frame_values: bytes, index: int = 5) -> "HardwareInfo":
|
|
159
|
+
v = frame_values
|
|
160
|
+
i = index
|
|
161
|
+
info = cls(raw=bytes(v))
|
|
162
|
+
info.device_id = [v[i + k] for k in range(8)]; i += 8
|
|
163
|
+
hw = [v[i + k] for k in range(4)]; i += 4
|
|
164
|
+
bt = [v[i + k] for k in range(4)]; i += 4
|
|
165
|
+
ap = [v[i + k] for k in range(4)]; i += 4
|
|
166
|
+
name = bytes(v[i:i + 10]); i += 10
|
|
167
|
+
info.hardware_version = "V{}.{}.{}.{}".format(*hw)
|
|
168
|
+
info.boot_version = "V{}.{}.{}.{}".format(*bt)
|
|
169
|
+
info.app_version = "V{}.{}.{}.{}".format(*ap)
|
|
170
|
+
info.device_name = name.split(b"\x00")[0].decode("ascii", "replace").strip()
|
|
171
|
+
return info
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pymp305
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python driver for the ISDT MP305 (MP305A/MP305B) smart bench power supply over USB-HID
|
|
5
|
+
Author-email: nemanjan00 <nemanjan00@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nemanjan00/pymp305
|
|
8
|
+
Project-URL: Repository, https://github.com/nemanjan00/pymp305
|
|
9
|
+
Project-URL: Issues, https://github.com/nemanjan00/pymp305/issues
|
|
10
|
+
Keywords: isdt,mp305,mp305a,mp305b,power-supply,psu,usb,hid,bench
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: hidapi>=0.14
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest; extra == "test"
|
|
27
|
+
|
|
28
|
+
# pymp305
|
|
29
|
+
|
|
30
|
+
A pure-Python driver for the **ISDT MP305** smart bench power supplies (**MP305A** and **MP305B**), talking to it
|
|
31
|
+
over **USB-HID** — the same transport the official [WebLink](https://www.isdt.co/weblink/)
|
|
32
|
+
web app uses. Reverse-engineered from WebLink's public source-maps; the full protocol is
|
|
33
|
+
documented in [`../PROTOCOL.md`](../PROTOCOL.md). (The recovered upstream JS used during
|
|
34
|
+
RE is ISDT's copyright and is kept locally under `reversing/`, which is git-ignored and
|
|
35
|
+
not published.)
|
|
36
|
+
|
|
37
|
+
> Status: written from the recovered firmware protocol but **not yet validated against
|
|
38
|
+
> real hardware** (the device hasn't arrived). The framing layer is covered by golden-vector
|
|
39
|
+
> tests (`tests/test_protocol.py`, all passing). See *Bring-up* below for first-run checks.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install hidapi # the only runtime dependency
|
|
45
|
+
pip install -e . # from this directory
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
On Linux you'll need permission to access the hidraw node. Either run as root for a quick
|
|
49
|
+
test, or add a udev rule (recommended):
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
# /etc/udev/rules.d/99-pymp305.rules
|
|
53
|
+
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="28e9", MODE="0660", TAG+="uaccess"
|
|
54
|
+
```
|
|
55
|
+
then `sudo udevadm control --reload && sudo udevadm trigger` and replug.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pymp305 import MP305
|
|
61
|
+
|
|
62
|
+
with MP305.open() as psu:
|
|
63
|
+
print(psu.hardware_info()) # name, hw/boot/app versions
|
|
64
|
+
|
|
65
|
+
psu.set_output(voltage=5.0, current=1.0, on=True) # takes remote control + enables output
|
|
66
|
+
|
|
67
|
+
st = psu.read_state()
|
|
68
|
+
print(f"{st.voltage:.2f} V {st.current:.3f} A {st.power:.2f} W {st.temperature} C")
|
|
69
|
+
|
|
70
|
+
psu.output_off()
|
|
71
|
+
psu.release_remote() # hand control back to the front panel
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
See [`examples/basic.py`](./examples/basic.py) for a live-streaming example.
|
|
75
|
+
|
|
76
|
+
## API surface
|
|
77
|
+
|
|
78
|
+
| Method | Does |
|
|
79
|
+
|--------|------|
|
|
80
|
+
| `MP305.list_devices()` | enumerate VID `0x28E9` HID interfaces |
|
|
81
|
+
| `MP305.open(path=None)` | open (auto-picks usage_page 0x01 / usage 0x04) |
|
|
82
|
+
| `hardware_info()` | `0xE0`→`0xE1` device id, firmware versions |
|
|
83
|
+
| `read_state(realtime=True)` | `0xBD`/`0xC2`→`0xC3` live V/I/W/Wh/temp/output (`State`) |
|
|
84
|
+
| `read_system_settings()` | `0xC4`→`0xC5` (`SystemSettings`) |
|
|
85
|
+
| `set_output(voltage, current, on, model=0)` | take control + apply, returns fresh `State` |
|
|
86
|
+
| `output_on()` / `output_off()` | toggle output |
|
|
87
|
+
| `release_remote()` | `remoteCon=0` — return control to the panel |
|
|
88
|
+
| `control(ControlCommand)` | low-level `0xC8` |
|
|
89
|
+
| `set_system_settings(SystemSetCommand)` | `0xC6` |
|
|
90
|
+
| `charge(ChargeCommand)` | `0xEE` battery-charge mode |
|
|
91
|
+
| `set_language(i)` | `0xA2` |
|
|
92
|
+
| `reboot()` / `enter_bootloader()` | danger zone |
|
|
93
|
+
| `send(cmd, payload)` / `request(cmd, expect, payload)` / `read_frame()` | raw access for PDO / programmable / OTA commands not yet wrapped |
|
|
94
|
+
|
|
95
|
+
Units are converted for you: `voltage`/`set_voltage` in **V**, `current`/`set_current`
|
|
96
|
+
in **A**, `power` in **W**, `energy` in **Wh**, `temperature` in **°C**, `working_time` in **s**.
|
|
97
|
+
|
|
98
|
+
## Bring-up checklist (first run with hardware)
|
|
99
|
+
|
|
100
|
+
1. `python -c "from pymp305 import MP305; print(MP305.list_devices())"` — confirm the
|
|
101
|
+
device shows up under VID `0x28E9` and note its `usage_page`/`usage`.
|
|
102
|
+
2. `psu.hardware_info()` — if the de-stuffed name/version look right, framing is correct.
|
|
103
|
+
3. If reads time out: the device may not use a fixed 64-byte report. Try
|
|
104
|
+
`MP305(dev, report_size=N)` with other sizes, or pass an explicit interface `path`.
|
|
105
|
+
4. `read_state()` polls with the realtime command (`0xBD`) like the app; if that yields
|
|
106
|
+
nothing, try `read_state(realtime=False)` (`0xC2`).
|
|
107
|
+
|
|
108
|
+
## Tests
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python tests/test_protocol.py # or: pytest
|
|
112
|
+
```
|
|
113
|
+
These validate the framing/checksum/stuffing and unit decoding without hardware.
|
|
114
|
+
|
|
115
|
+
## BLE (not implemented here)
|
|
116
|
+
|
|
117
|
+
The MP305 also speaks the **same command set over BLE** (used by the PolyLink phone app):
|
|
118
|
+
GATT service `0000af00-…`, characteristic `af01` for command/notify, `af02` for the
|
|
119
|
+
binding handshake and chunked writes, plus `fee0/fee1` for OTA. BLE frames drop the
|
|
120
|
+
length/0xAA/checksum wrapper and are just `[0x12, cmd, …payload]`. Wrapping that with
|
|
121
|
+
`bleak` would reuse `responses.py` directly — left as a future addition.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pymp305/__init__.py,sha256=3j8i7vp8BjVDCpGwJhRieLrfOxGKxqWRQM07pOjfifA,986
|
|
2
|
+
pymp305/device.py,sha256=AGTjbciKgr1XWdBcx3s55bAGOHOJA9HpurzEjVACs0c,10338
|
|
3
|
+
pymp305/protocol.py,sha256=RITOhQWmeHuNz3r8FTVNcD-KbncEcmgW-PVSC0Sfcjk,4403
|
|
4
|
+
pymp305/responses.py,sha256=-LkXHmsVaqnA3TlPkElSOdsWetxsW5Q16ZyWFfvDt4s,6152
|
|
5
|
+
pymp305-0.1.0.dist-info/METADATA,sha256=gaS-7acvEnniIxG8T14h-SfEW0kKjIJVXo3HY3J2XIM,5375
|
|
6
|
+
pymp305-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
pymp305-0.1.0.dist-info/top_level.txt,sha256=muKwjJV2O7m9cNWU7x-drOeOmJcXwC0ePKJJ9DqQCjY,8
|
|
8
|
+
pymp305-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pymp305
|