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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pymp305