pmd-pycore 0.2.3__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.
- pmd_pycore/__init__.py +15 -0
- pmd_pycore/core/__init__.py +101 -0
- pmd_pycore/core/pmd2.py +407 -0
- pmd_pycore/core/pmdusb.py +264 -0
- pmd_pycore/version.py +5 -0
- pmd_pycore-0.2.3.dist-info/METADATA +150 -0
- pmd_pycore-0.2.3.dist-info/RECORD +9 -0
- pmd_pycore-0.2.3.dist-info/WHEEL +5 -0
- pmd_pycore-0.2.3.dist-info/top_level.txt +1 -0
pmd_pycore/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# pmd_pycore/__init__.py
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as get_version, PackageNotFoundError
|
|
4
|
+
from .version import __version__
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
installed_version = get_version("pmd-pycore")
|
|
8
|
+
if installed_version != __version__:
|
|
9
|
+
__version__ = installed_version
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
pass # Use the version from version.py
|
|
12
|
+
|
|
13
|
+
from . import core
|
|
14
|
+
|
|
15
|
+
__all__ = ["core", "__version__"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# pmd_pycore/core/__init__.py
|
|
2
|
+
|
|
3
|
+
from .pmd2 import (
|
|
4
|
+
get_pmd2_ports,
|
|
5
|
+
find_pmd2_devices,
|
|
6
|
+
read_pmd2_uart,
|
|
7
|
+
read_pmd2_welcome_string,
|
|
8
|
+
read_pmd2_device,
|
|
9
|
+
read_pmd2_uid,
|
|
10
|
+
read_pmd2_sensors,
|
|
11
|
+
translate_pmd2_sensor_struct,
|
|
12
|
+
translate_pmd2_aggregate_power,
|
|
13
|
+
translate_pmd2_chip_sensors,
|
|
14
|
+
translate_pmd2x_hpwr_temperatures,
|
|
15
|
+
PMD2_CHANNELS,
|
|
16
|
+
PMD2X_CHANNELS,
|
|
17
|
+
PMD2VendorDataStruct,
|
|
18
|
+
PMD2PowerSensor,
|
|
19
|
+
PMD2SensorStruct,
|
|
20
|
+
PMD2XSensorStruct,
|
|
21
|
+
PMD2_CMD,
|
|
22
|
+
PMD2_VENDOR_ID,
|
|
23
|
+
PMD2_PRODUCT_ID,
|
|
24
|
+
PMD2X_PRODUCT_ID,
|
|
25
|
+
PMD2_PRODUCT_STRING,
|
|
26
|
+
PMD2X_PRODUCT_STRING,
|
|
27
|
+
PMD2_SENSOR_POWER_NUM,
|
|
28
|
+
PMD2X_SENSOR_POWER_NUM,
|
|
29
|
+
PMD2X_SENSOR_TS_NUM,
|
|
30
|
+
PMD2X_TS_IN,
|
|
31
|
+
PMD2X_TS_OUT,
|
|
32
|
+
PMD2X_TS_NOT_CONNECTED,
|
|
33
|
+
)
|
|
34
|
+
from .pmdusb import (
|
|
35
|
+
get_pmdusb_ports,
|
|
36
|
+
read_pmdusb_uart,
|
|
37
|
+
read_pmdusb_welcome_string,
|
|
38
|
+
read_pmdusb_id,
|
|
39
|
+
read_pmdusb_sensors,
|
|
40
|
+
translate_pmdusb_sensors,
|
|
41
|
+
translate_pmdusb_aggregate_power,
|
|
42
|
+
PMDUSBIdStruct,
|
|
43
|
+
PMDUSBChannelValues,
|
|
44
|
+
PMDUSBSensorStruct,
|
|
45
|
+
PMDUSB_CMD,
|
|
46
|
+
PMDUSB_VENDOR_ID,
|
|
47
|
+
PMDUSB_PRODUCT_ID,
|
|
48
|
+
PMDUSB_PRODUCT_STRING,
|
|
49
|
+
PMDUSB_CHANNEL_NUM,
|
|
50
|
+
PMDUSB_CHANNELS,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# pmd2
|
|
55
|
+
"get_pmd2_ports",
|
|
56
|
+
"find_pmd2_devices",
|
|
57
|
+
"read_pmd2_uart",
|
|
58
|
+
"read_pmd2_welcome_string",
|
|
59
|
+
"read_pmd2_device",
|
|
60
|
+
"read_pmd2_uid",
|
|
61
|
+
"read_pmd2_sensors",
|
|
62
|
+
"translate_pmd2_sensor_struct",
|
|
63
|
+
"translate_pmd2_aggregate_power",
|
|
64
|
+
"translate_pmd2_chip_sensors",
|
|
65
|
+
"translate_pmd2x_hpwr_temperatures",
|
|
66
|
+
"PMD2_CHANNELS",
|
|
67
|
+
"PMD2X_CHANNELS",
|
|
68
|
+
"PMD2VendorDataStruct",
|
|
69
|
+
"PMD2PowerSensor",
|
|
70
|
+
"PMD2SensorStruct",
|
|
71
|
+
"PMD2XSensorStruct",
|
|
72
|
+
"PMD2_CMD",
|
|
73
|
+
"PMD2_VENDOR_ID",
|
|
74
|
+
"PMD2_PRODUCT_ID",
|
|
75
|
+
"PMD2X_PRODUCT_ID",
|
|
76
|
+
"PMD2_PRODUCT_STRING",
|
|
77
|
+
"PMD2X_PRODUCT_STRING",
|
|
78
|
+
"PMD2_SENSOR_POWER_NUM",
|
|
79
|
+
"PMD2X_SENSOR_POWER_NUM",
|
|
80
|
+
"PMD2X_SENSOR_TS_NUM",
|
|
81
|
+
"PMD2X_TS_IN",
|
|
82
|
+
"PMD2X_TS_OUT",
|
|
83
|
+
"PMD2X_TS_NOT_CONNECTED",
|
|
84
|
+
# pmdusb
|
|
85
|
+
"get_pmdusb_ports",
|
|
86
|
+
"read_pmdusb_uart",
|
|
87
|
+
"read_pmdusb_welcome_string",
|
|
88
|
+
"read_pmdusb_id",
|
|
89
|
+
"read_pmdusb_sensors",
|
|
90
|
+
"translate_pmdusb_sensors",
|
|
91
|
+
"translate_pmdusb_aggregate_power",
|
|
92
|
+
"PMDUSBIdStruct",
|
|
93
|
+
"PMDUSBChannelValues",
|
|
94
|
+
"PMDUSBSensorStruct",
|
|
95
|
+
"PMDUSB_CMD",
|
|
96
|
+
"PMDUSB_VENDOR_ID",
|
|
97
|
+
"PMDUSB_PRODUCT_ID",
|
|
98
|
+
"PMDUSB_PRODUCT_STRING",
|
|
99
|
+
"PMDUSB_CHANNEL_NUM",
|
|
100
|
+
"PMDUSB_CHANNELS",
|
|
101
|
+
]
|
pmd_pycore/core/pmd2.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire-format structures, serial I/O, and sensor translation for ElmorLabs PMD2/PMD2X.
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
>>> from pmd_pycore.core import get_pmd2_ports, read_pmd2_device, read_pmd2_sensors
|
|
6
|
+
>>> ports = get_pmd2_ports()
|
|
7
|
+
>>> if ports:
|
|
8
|
+
>>> import serial
|
|
9
|
+
>>> ser = serial.Serial(ports[0]['port'], 115200, timeout=1,
|
|
10
|
+
>>> rtscts=False, dsrdtr=False)
|
|
11
|
+
>>> info = read_pmd2_device(ser)
|
|
12
|
+
>>> sensors = read_pmd2_sensors(ser, info['ProductId'])
|
|
13
|
+
>>> ser.close()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import logging
|
|
20
|
+
import serial
|
|
21
|
+
import serial.tools.list_ports
|
|
22
|
+
from ctypes import LittleEndianStructure, c_uint8, c_int16, c_int32, c_uint16, sizeof
|
|
23
|
+
from enum import IntEnum
|
|
24
|
+
from typing import List, Dict, Optional, Union
|
|
25
|
+
|
|
26
|
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
27
|
+
logger = logging.getLogger("pmd_pycore.core.pmd2")
|
|
28
|
+
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
|
29
|
+
|
|
30
|
+
if not logger.handlers:
|
|
31
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
32
|
+
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
|
33
|
+
handler.setFormatter(formatter)
|
|
34
|
+
logger.addHandler(handler)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Structures ---
|
|
38
|
+
|
|
39
|
+
PMD2_VENDOR_ID = 0xEE
|
|
40
|
+
PMD2_PRODUCT_ID = 0x15
|
|
41
|
+
PMD2X_PRODUCT_ID = 0x17
|
|
42
|
+
|
|
43
|
+
PMD2_PRODUCT_STRING = "ElmorLabs PMD2"
|
|
44
|
+
PMD2X_PRODUCT_STRING = "ElmorLabs PMD2X"
|
|
45
|
+
|
|
46
|
+
PMD2_SENSOR_POWER_NUM = 10
|
|
47
|
+
PMD2X_SENSOR_POWER_NUM = 16
|
|
48
|
+
PMD2X_SENSOR_TS_NUM = 2
|
|
49
|
+
|
|
50
|
+
PMD2X_TS_IN = 0 # 12VHPWR connector temperature sensor, cable side
|
|
51
|
+
PMD2X_TS_OUT = 1 # 12VHPWR connector temperature sensor, PSU/device side
|
|
52
|
+
PMD2X_TS_NOT_CONNECTED = 0x7FFF
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PMD2_CMD(IntEnum):
|
|
56
|
+
"""USB_CMD, per PMD2-FW's pmd2.h (private repo).
|
|
57
|
+
|
|
58
|
+
CMD_READ_DEVICE_DATA is declared there but has no case in the device's
|
|
59
|
+
CDC_Receive_FS dispatch (usbd_cdc_if.c) -- sending it is a no-op.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
CMD_WELCOME = 0
|
|
63
|
+
CMD_READ_VENDOR_DATA = 1
|
|
64
|
+
CMD_READ_UID = 2
|
|
65
|
+
CMD_READ_DEVICE_DATA = 3
|
|
66
|
+
CMD_READ_SENSOR_VALUES = 4
|
|
67
|
+
CMD_WRITE_CONT_TX = 5
|
|
68
|
+
CMD_READ_CONFIG = 6
|
|
69
|
+
CMD_WRITE_CONFIG = 7
|
|
70
|
+
CMD_RESET = 0xF0
|
|
71
|
+
CMD_BOOTLOADER = 0xF1
|
|
72
|
+
CMD_MCU_CMD = 0xF2
|
|
73
|
+
CMD_NOP = 0xFF
|
|
74
|
+
|
|
75
|
+
def toByte(self) -> bytes:
|
|
76
|
+
return self.value.to_bytes(1, byteorder="little")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PMD2VendorDataStruct(LittleEndianStructure):
|
|
80
|
+
_pack_ = 1
|
|
81
|
+
_fields_ = [
|
|
82
|
+
("VendorId", c_uint8),
|
|
83
|
+
("ProductId", c_uint8),
|
|
84
|
+
("FwVersion", c_uint8),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
def __str__(self):
|
|
88
|
+
return (
|
|
89
|
+
f"VendorId: {hex(self.VendorId)}, "
|
|
90
|
+
f"ProductId: {hex(self.ProductId)}, "
|
|
91
|
+
f"FwVersion: {hex(self.FwVersion)}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PMD2PowerSensor(LittleEndianStructure):
|
|
96
|
+
_pack_ = 1
|
|
97
|
+
_fields_ = [
|
|
98
|
+
("Voltage", c_int16),
|
|
99
|
+
("Current", c_int32),
|
|
100
|
+
("Power", c_int32),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def __str__(self):
|
|
104
|
+
return f"Voltage: {self.Voltage}, Current: {self.Current}, Power: {self.Power}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PMD2SensorStruct(LittleEndianStructure):
|
|
108
|
+
_pack_ = 1
|
|
109
|
+
_fields_ = [
|
|
110
|
+
("Vdd", c_uint16),
|
|
111
|
+
("Tchip", c_int16),
|
|
112
|
+
("PowerReadings", PMD2PowerSensor * PMD2_SENSOR_POWER_NUM),
|
|
113
|
+
("EpsPower", c_uint16),
|
|
114
|
+
("PciePower", c_uint16),
|
|
115
|
+
("MbPower", c_uint16),
|
|
116
|
+
("TotalPower", c_uint16),
|
|
117
|
+
("Ocp", c_uint8 * PMD2_SENSOR_POWER_NUM),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PMD2XSensorStruct(LittleEndianStructure):
|
|
122
|
+
_pack_ = 1
|
|
123
|
+
_fields_ = [
|
|
124
|
+
("Vdd", c_uint16),
|
|
125
|
+
("Tchip", c_int16),
|
|
126
|
+
("Ts", c_int16 * PMD2X_SENSOR_TS_NUM),
|
|
127
|
+
("PowerReadings", PMD2PowerSensor * PMD2X_SENSOR_POWER_NUM),
|
|
128
|
+
("EpsPower", c_uint16),
|
|
129
|
+
("PciePower", c_uint16),
|
|
130
|
+
("MbPower", c_uint16),
|
|
131
|
+
("TotalPower", c_uint16),
|
|
132
|
+
("Ocp", c_uint8 * PMD2X_SENSOR_POWER_NUM),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- Serial I/O ---
|
|
137
|
+
|
|
138
|
+
# VID:PID shared by PMD2 and PMD2X (checked case-insensitively).
|
|
139
|
+
_PMD2_VID_PID = "VID:PID=0483:5740"
|
|
140
|
+
|
|
141
|
+
# Longest welcome string a device can push on connect.
|
|
142
|
+
_WELCOME_PUSH_MAX_LEN = max(len(PMD2_PRODUCT_STRING), len(PMD2X_PRODUCT_STRING)) + 1
|
|
143
|
+
|
|
144
|
+
# CMD_WELCOME replies with BuildData.ProductName truncated to 15 bytes
|
|
145
|
+
# (CDC_Transmit_FS((void*)BuildData.ProductName, 15) in usbd_cdc_if.c) --
|
|
146
|
+
# a fixed size independent of the unsolicited DTR-triggered push above.
|
|
147
|
+
_WELCOME_REPLY_LEN = 15
|
|
148
|
+
|
|
149
|
+
_UID_LEN = 12
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_pmd2_ports() -> List[Dict[str, str]]:
|
|
153
|
+
"""Return all candidate PMD2/PMD2X COM ports without opening them.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List[Dict[str, str]]: List of dicts with a 'port' key, e.g. [{'port': 'COM3'}].
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
ports = serial.tools.list_ports.comports()
|
|
160
|
+
except serial.SerialException as e:
|
|
161
|
+
logger.error("Failed to enumerate serial ports: %s", e)
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
pmd2_ports = []
|
|
165
|
+
for p in ports:
|
|
166
|
+
if _PMD2_VID_PID in p.hwid.upper():
|
|
167
|
+
pmd2_ports.append({"port": p.device})
|
|
168
|
+
return pmd2_ports
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _drain_pmd2_welcome_push(ser: serial.Serial) -> Optional[str]:
|
|
172
|
+
"""Read and decode the unsolicited welcome string a device pushes after DTR is asserted."""
|
|
173
|
+
buffer = ser.read(_WELCOME_PUSH_MAX_LEN)
|
|
174
|
+
try:
|
|
175
|
+
return buffer.decode("ascii").rstrip("\x00")
|
|
176
|
+
except UnicodeDecodeError:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def find_pmd2_devices() -> List[Dict[str, str]]:
|
|
181
|
+
"""Scan and return all connected PMD2/PMD2X devices with identity info.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List[Dict[str, str]]: List of dicts with 'port', 'product_id', 'fw' keys.
|
|
185
|
+
|
|
186
|
+
Note:
|
|
187
|
+
This opens and closes a serial connection per candidate port.
|
|
188
|
+
"""
|
|
189
|
+
devices = []
|
|
190
|
+
for port_info in get_pmd2_ports():
|
|
191
|
+
port = port_info["port"]
|
|
192
|
+
product_id, fw = "?", "?"
|
|
193
|
+
try:
|
|
194
|
+
with serial.Serial(port, 115200, timeout=1, rtscts=False, dsrdtr=False) as ser:
|
|
195
|
+
ser.dtr = True
|
|
196
|
+
_drain_pmd2_welcome_push(ser)
|
|
197
|
+
device_info = read_pmd2_device(ser)
|
|
198
|
+
if device_info:
|
|
199
|
+
product_id = device_info["ProductId"]
|
|
200
|
+
fw = device_info["FwVersion"]
|
|
201
|
+
logger.info("Found device on %s: ProductId=%s, FW=%s", port, product_id, fw)
|
|
202
|
+
except serial.SerialException as e:
|
|
203
|
+
logger.warning("Failed to open serial port %s: %s", port, e)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.warning("Failed to read device on %s: %s", port, e)
|
|
206
|
+
devices.append({"port": port, "product_id": product_id, "fw": fw})
|
|
207
|
+
return devices
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def read_pmd2_uart(ser: serial.Serial, cmd: PMD2_CMD, size: int) -> Optional[bytes]:
|
|
211
|
+
"""Send a command and read `size` bytes from the device.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Optional[bytes]: Raw bytes read, or None if the read was incomplete.
|
|
215
|
+
"""
|
|
216
|
+
ser.reset_input_buffer()
|
|
217
|
+
ser.write(cmd.toByte())
|
|
218
|
+
# Without a short gap the device occasionally hasn't queued its response
|
|
219
|
+
# yet when we read. 2ms was 0/5000 on a PMD2X but 2-3% failures on a
|
|
220
|
+
# PMD2 (different unit/firmware); 5ms was 0/5000 on both -- cheap
|
|
221
|
+
# insurance against a read that otherwise blocks for the full port
|
|
222
|
+
# timeout before coming back empty.
|
|
223
|
+
time.sleep(0.005)
|
|
224
|
+
buffer = ser.read(size)
|
|
225
|
+
if len(buffer) != size:
|
|
226
|
+
logger.warning("UART read incomplete: expected %d, got %d bytes", size, len(buffer))
|
|
227
|
+
return None
|
|
228
|
+
return buffer
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def read_pmd2_device(ser: serial.Serial) -> Optional[Dict[str, int]]:
|
|
232
|
+
"""Read and validate vendor/product/firmware info from the device.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Optional[Dict[str, int]]: Dict with 'VendorId', 'ProductId', 'FwVersion',
|
|
236
|
+
or None if the read failed or the vendor ID didn't match.
|
|
237
|
+
"""
|
|
238
|
+
buffer = read_pmd2_uart(ser, PMD2_CMD.CMD_READ_VENDOR_DATA, sizeof(PMD2VendorDataStruct))
|
|
239
|
+
if buffer is None:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
vendor_struct = PMD2VendorDataStruct.from_buffer_copy(buffer)
|
|
243
|
+
if vendor_struct.VendorId != PMD2_VENDOR_ID or vendor_struct.ProductId not in (
|
|
244
|
+
PMD2_PRODUCT_ID,
|
|
245
|
+
PMD2X_PRODUCT_ID,
|
|
246
|
+
):
|
|
247
|
+
logger.warning("Unexpected vendor data: %s", vendor_struct)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"VendorId": vendor_struct.VendorId,
|
|
252
|
+
"ProductId": vendor_struct.ProductId,
|
|
253
|
+
"FwVersion": vendor_struct.FwVersion,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def read_pmd2_welcome_string(ser: serial.Serial) -> Optional[str]:
|
|
258
|
+
"""Send CMD_WELCOME and return the device's product name.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Optional[str]: Decoded product name, or None if the read failed.
|
|
262
|
+
"""
|
|
263
|
+
buffer = read_pmd2_uart(ser, PMD2_CMD.CMD_WELCOME, _WELCOME_REPLY_LEN)
|
|
264
|
+
if buffer is None:
|
|
265
|
+
return None
|
|
266
|
+
try:
|
|
267
|
+
return buffer.decode("ascii").rstrip("\x00")
|
|
268
|
+
except UnicodeDecodeError:
|
|
269
|
+
logger.warning("Failed to decode welcome string: %r", buffer)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def read_pmd2_uid(ser: serial.Serial) -> Optional[str]:
|
|
274
|
+
"""Read the device's unique ID (the STM32's hardware UID).
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Optional[str]: UID as an uppercase hex string, or None if the read failed.
|
|
278
|
+
"""
|
|
279
|
+
buffer = read_pmd2_uart(ser, PMD2_CMD.CMD_READ_UID, _UID_LEN)
|
|
280
|
+
if buffer is None:
|
|
281
|
+
return None
|
|
282
|
+
return buffer.hex().upper()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def read_pmd2_sensors(
|
|
286
|
+
ser: serial.Serial,
|
|
287
|
+
product_id: int,
|
|
288
|
+
) -> Optional[Union[PMD2SensorStruct, PMD2XSensorStruct]]:
|
|
289
|
+
"""Read all sensor values from the device.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
ser (serial.Serial): Opened serial connection to the device.
|
|
293
|
+
product_id (int): PMD2_PRODUCT_ID or PMD2X_PRODUCT_ID, selecting which
|
|
294
|
+
sensor structure to decode the response as.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Optional[Union[PMD2SensorStruct, PMD2XSensorStruct]]: Decoded sensor
|
|
298
|
+
structure, or None if the read failed.
|
|
299
|
+
"""
|
|
300
|
+
struct_type = PMD2XSensorStruct if product_id == PMD2X_PRODUCT_ID else PMD2SensorStruct
|
|
301
|
+
buffer = read_pmd2_uart(ser, PMD2_CMD.CMD_READ_SENSOR_VALUES, sizeof(struct_type))
|
|
302
|
+
if buffer is None:
|
|
303
|
+
logger.warning("Failed to read sensors from device")
|
|
304
|
+
return None
|
|
305
|
+
return struct_type.from_buffer_copy(buffer)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --- Sensor translation ---
|
|
309
|
+
|
|
310
|
+
# Channel index -> rail name, for each product. Matches SENSOR_POWER_* in
|
|
311
|
+
# the PMD2-FW pmd2.h header (private repo).
|
|
312
|
+
# Index 4 ("HPWR"/"HPWR1") is computed by the firmware itself from the six
|
|
313
|
+
# wire channels (10-15) -- it isn't something the host needs to average.
|
|
314
|
+
PMD2_CHANNELS = {
|
|
315
|
+
0: "12V", 1: "5V", 2: "5VSB", 3: "3.3V",
|
|
316
|
+
4: "HPWR",
|
|
317
|
+
5: "EPS1", 6: "EPS2",
|
|
318
|
+
7: "PCIE1", 8: "PCIE2", 9: "PCIE3",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
PMD2X_CHANNELS = {
|
|
322
|
+
0: "12V", 1: "5V", 2: "5VSB", 3: "3.3V",
|
|
323
|
+
4: "HPWR",
|
|
324
|
+
5: "EPS1", 6: "EPS2",
|
|
325
|
+
7: "PCIE1", 8: "PCIE2", 9: "PCIE3",
|
|
326
|
+
10: "HPWR_W1", 11: "HPWR_W2", 12: "HPWR_W3",
|
|
327
|
+
13: "HPWR_W4", 14: "HPWR_W5", 15: "HPWR_W6",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _pmd2_reading(voltage: int, current: int, power: int) -> Dict[str, float]:
|
|
332
|
+
return {"voltage": voltage / 1000, "current": current / 1000, "power": power / 1000}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def translate_pmd2_sensor_struct(
|
|
336
|
+
sensors: Union[PMD2SensorStruct, PMD2XSensorStruct],
|
|
337
|
+
product_id: int,
|
|
338
|
+
) -> Dict[str, Dict[str, float]]:
|
|
339
|
+
"""Convert a raw sensor struct into a dict of rail name -> {voltage, current, power} (SI units).
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
sensors: Decoded sensor struct from read_pmd2_sensors().
|
|
343
|
+
product_id: PMD2_PRODUCT_ID or PMD2X_PRODUCT_ID.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Dict[str, Dict[str, float]]: Rail name -> reading.
|
|
347
|
+
"""
|
|
348
|
+
channels = PMD2_CHANNELS if product_id == PMD2_PRODUCT_ID else PMD2X_CHANNELS
|
|
349
|
+
readings = sensors.PowerReadings
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
name: _pmd2_reading(readings[idx].Voltage, readings[idx].Current, readings[idx].Power)
|
|
353
|
+
for idx, name in channels.items()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def translate_pmd2_aggregate_power(
|
|
358
|
+
sensors: Union[PMD2SensorStruct, PMD2XSensorStruct],
|
|
359
|
+
) -> Dict[str, float]:
|
|
360
|
+
"""Read the firmware-computed power aggregates, in watts.
|
|
361
|
+
|
|
362
|
+
The device sums these on-board rather than requiring the host to add up
|
|
363
|
+
individual rails:
|
|
364
|
+
- eps: EPS/CPU power (EPS1 + EPS2)
|
|
365
|
+
- pcie: PCIe/GPU power (PCIE1-3, plus HPWR on PMD2X)
|
|
366
|
+
- mb: motherboard power (ATX24 rails: 12V/5V/5VSB/3.3V)
|
|
367
|
+
- total: sum of all of the above
|
|
368
|
+
"""
|
|
369
|
+
return {
|
|
370
|
+
"eps": sensors.EpsPower / 1000,
|
|
371
|
+
"pcie": sensors.PciePower / 1000,
|
|
372
|
+
"mb": sensors.MbPower / 1000,
|
|
373
|
+
"total": sensors.TotalPower / 1000,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def translate_pmd2_chip_sensors(sensors: Union[PMD2SensorStruct, PMD2XSensorStruct]) -> Dict[str, float]:
|
|
378
|
+
"""Read the device's own supply voltage and chip temperature.
|
|
379
|
+
|
|
380
|
+
Tchip is in tenths of a degree C, per sensor.c's TH_GetTs()/
|
|
381
|
+
__LL_ADC_CALC_TEMPERATURE() usage -- confirmed against a live device
|
|
382
|
+
(raw 320 read back as a plausible 32.0 C, not 320 C).
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Dict[str, float]: 'vdd' in volts, 'tchip' in degrees Celsius.
|
|
386
|
+
"""
|
|
387
|
+
return {"vdd": sensors.Vdd / 1000, "tchip": sensors.Tchip / 10}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def translate_pmd2x_hpwr_temperatures(sensors: PMD2XSensorStruct) -> Dict[str, Optional[float]]:
|
|
391
|
+
"""Read the 12VHPWR connector's two thermistor temperatures (PMD2X only), in Celsius.
|
|
392
|
+
|
|
393
|
+
Ts[] comes from TH_GetTs() (see PMD2-FW's th.c/th_lut.h), which is also
|
|
394
|
+
in tenths of a degree C. 0x7FFF means the thermistor read out of range,
|
|
395
|
+
i.e. nothing is plugged in.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Dict[str, Optional[float]]: 'ts_in' and 'ts_out'; either is None if
|
|
399
|
+
that thermistor is not connected.
|
|
400
|
+
"""
|
|
401
|
+
def _value(raw: int) -> Optional[float]:
|
|
402
|
+
return None if raw == PMD2X_TS_NOT_CONNECTED else raw / 10
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
"ts_in": _value(sensors.Ts[PMD2X_TS_IN]),
|
|
406
|
+
"ts_out": _value(sensors.Ts[PMD2X_TS_OUT]),
|
|
407
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire-format structures, serial I/O, and sensor translation for the ElmorLabs PMD-USB.
|
|
3
|
+
|
|
4
|
+
PMD-USB is a different device family from PMD2/PMD2X: it connects via a
|
|
5
|
+
CH340 USB-serial bridge rather than an STM32 CDC-ACM VCP, and uses its own
|
|
6
|
+
command numbering and sensor layout (PCIE1/PCIE2/EPS1/EPS2 only -- no ATX24
|
|
7
|
+
rails, no 12VHPWR). There is no public firmware source; this was ported
|
|
8
|
+
from and verified against:
|
|
9
|
+
https://github.com/ElmorLabs/PMDLogger (C# reference, PMD_USB_Device.cs)
|
|
10
|
+
https://github.com/ElmorLabs/PMD-USB (official GUI app, FormPMD.cs)
|
|
11
|
+
https://github.com/ElmorLabs/pmd-usb-serial-interface-fast
|
|
12
|
+
https://github.com/bjorntas/elmorlabs-pmd-usb-serial-interface
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from pmd_pycore.core import get_pmdusb_ports, read_pmdusb_id, read_pmdusb_sensors
|
|
16
|
+
>>> ports = get_pmdusb_ports()
|
|
17
|
+
>>> if ports:
|
|
18
|
+
>>> import serial
|
|
19
|
+
>>> ser = serial.Serial(ports[0]['port'], 115200, timeout=1)
|
|
20
|
+
>>> info = read_pmdusb_id(ser)
|
|
21
|
+
>>> sensors = read_pmdusb_sensors(ser)
|
|
22
|
+
>>> ser.close()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import logging
|
|
29
|
+
import serial
|
|
30
|
+
import serial.tools.list_ports
|
|
31
|
+
from ctypes import LittleEndianStructure, c_uint8, c_int16, sizeof
|
|
32
|
+
from enum import IntEnum
|
|
33
|
+
from typing import List, Dict, Optional
|
|
34
|
+
|
|
35
|
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
36
|
+
logger = logging.getLogger("pmd_pycore.core.pmdusb")
|
|
37
|
+
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
|
38
|
+
|
|
39
|
+
if not logger.handlers:
|
|
40
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
41
|
+
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
|
42
|
+
handler.setFormatter(formatter)
|
|
43
|
+
logger.addHandler(handler)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- Structures ---
|
|
47
|
+
|
|
48
|
+
PMDUSB_VENDOR_ID = 0xEE
|
|
49
|
+
PMDUSB_PRODUCT_ID = 0x0A
|
|
50
|
+
|
|
51
|
+
PMDUSB_PRODUCT_STRING = "ElmorLabs PMD-USB"
|
|
52
|
+
|
|
53
|
+
PMDUSB_CHANNEL_NUM = 4
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PMDUSB_CMD(IntEnum):
|
|
57
|
+
"""UART_CMD, per PMDLogger's PMD_USB_Device.cs and the official GUI app's FormPMD.cs."""
|
|
58
|
+
|
|
59
|
+
CMD_WELCOME = 0
|
|
60
|
+
CMD_READ_ID = 1
|
|
61
|
+
CMD_READ_SENSORS = 2
|
|
62
|
+
CMD_READ_SENSOR_VALUES = 3
|
|
63
|
+
CMD_READ_CONFIG = 4
|
|
64
|
+
CMD_WRITE_CONFIG = 5
|
|
65
|
+
CMD_READ_ADC = 6
|
|
66
|
+
CMD_WRITE_CONFIG_CONT_TX = 7
|
|
67
|
+
CMD_WRITE_CONFIG_UART = 8
|
|
68
|
+
CMD_RESET = 0xF0
|
|
69
|
+
CMD_BOOTLOADER = 0xF1
|
|
70
|
+
CMD_NOP = 0xFF
|
|
71
|
+
|
|
72
|
+
def toByte(self) -> bytes:
|
|
73
|
+
return self.value.to_bytes(1, byteorder="little")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PMDUSBIdStruct(LittleEndianStructure):
|
|
77
|
+
_pack_ = 1
|
|
78
|
+
_fields_ = [
|
|
79
|
+
("VendorId", c_uint8),
|
|
80
|
+
("ProductId", c_uint8),
|
|
81
|
+
("FwVersion", c_uint8),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def __str__(self):
|
|
85
|
+
return (
|
|
86
|
+
f"VendorId: {hex(self.VendorId)}, "
|
|
87
|
+
f"ProductId: {hex(self.ProductId)}, "
|
|
88
|
+
f"FwVersion: {hex(self.FwVersion)}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PMDUSBChannelValues(LittleEndianStructure):
|
|
93
|
+
_pack_ = 1
|
|
94
|
+
_fields_ = [
|
|
95
|
+
("Voltage", c_int16),
|
|
96
|
+
("Current", c_int16),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PMDUSBSensorStruct(LittleEndianStructure):
|
|
101
|
+
"""CMD_READ_SENSOR_VALUES reply: 4 channels x {Voltage, Current}, 16 bytes total.
|
|
102
|
+
|
|
103
|
+
Voltage is raw*0.01 V, current is raw*0.1 A -- plain signed int16.
|
|
104
|
+
Already calibrated on-device (confirmed against the official GUI app's
|
|
105
|
+
live monitoring loop, which uses this command directly with no
|
|
106
|
+
client-side offset/gain correction); CMD_READ_ADC's raw-ADC path isn't
|
|
107
|
+
needed or implemented here.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_pack_ = 1
|
|
111
|
+
_fields_ = [
|
|
112
|
+
("Channels", PMDUSBChannelValues * PMDUSB_CHANNEL_NUM),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Channel index -> rail name, per PMDLogger's PMD_USB_Device constructor and
|
|
117
|
+
# bjorntas's elmorlabs-pmd-usb-serial-interface main.py.
|
|
118
|
+
PMDUSB_CHANNELS = {
|
|
119
|
+
0: "PCIE1",
|
|
120
|
+
1: "PCIE2",
|
|
121
|
+
2: "EPS1",
|
|
122
|
+
3: "EPS2",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Channel indices feeding each aggregate, matching the official PMD-USB GUI
|
|
126
|
+
# app's update_task() (FormPMD.cs): PCIE1+PCIE2 -> GPU, EPS1+EPS2 -> CPU.
|
|
127
|
+
_GPU_CHANNELS = (0, 1)
|
|
128
|
+
_CPU_CHANNELS = (2, 3)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Serial I/O ---
|
|
132
|
+
|
|
133
|
+
# PMD-USB uses a CH340 USB-serial bridge, not the STM32 VCP that PMD2/PMD2X
|
|
134
|
+
# use -- a different VID:PID entirely.
|
|
135
|
+
_PMDUSB_VID_PID = "VID:PID=1A86:7523"
|
|
136
|
+
|
|
137
|
+
_WELCOME_REPLY_LEN = len(PMDUSB_PRODUCT_STRING)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_pmdusb_ports() -> List[Dict[str, str]]:
|
|
141
|
+
"""Return all candidate PMD-USB COM ports without opening them.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
List[Dict[str, str]]: List of dicts with a 'port' key, e.g. [{'port': 'COM3'}].
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
ports = serial.tools.list_ports.comports()
|
|
148
|
+
except serial.SerialException as e:
|
|
149
|
+
logger.error("Failed to enumerate serial ports: %s", e)
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
pmdusb_ports = []
|
|
153
|
+
for p in ports:
|
|
154
|
+
if _PMDUSB_VID_PID in p.hwid.upper():
|
|
155
|
+
pmdusb_ports.append({"port": p.device})
|
|
156
|
+
return pmdusb_ports
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_pmdusb_uart(ser: serial.Serial, cmd: PMDUSB_CMD, size: int) -> Optional[bytes]:
|
|
160
|
+
"""Send a command and read `size` bytes from the device.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Optional[bytes]: Raw bytes read, or None if the read was incomplete.
|
|
164
|
+
"""
|
|
165
|
+
ser.reset_input_buffer()
|
|
166
|
+
ser.write(cmd.toByte())
|
|
167
|
+
# Measured 0/5000 failures at 0ms across 5 trials against a live
|
|
168
|
+
# PMD-USB (its CH340 bridge responds faster than PMD2/PMD2X's STM32
|
|
169
|
+
# CDC-ACM stack did), but a small margin is kept anyway -- that same
|
|
170
|
+
# "0ms looked fine" read on PMD2/PMD2X didn't hold up under more
|
|
171
|
+
# sustained testing, and a cheap sleep costs far less than a stall on
|
|
172
|
+
# the read timeout if it's ever wrong on different hardware.
|
|
173
|
+
time.sleep(0.002)
|
|
174
|
+
buffer = ser.read(size)
|
|
175
|
+
if len(buffer) != size:
|
|
176
|
+
logger.warning("UART read incomplete: expected %d, got %d bytes", size, len(buffer))
|
|
177
|
+
return None
|
|
178
|
+
return buffer
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def read_pmdusb_welcome_string(ser: serial.Serial) -> Optional[str]:
|
|
182
|
+
"""Send CMD_WELCOME and return the device's product name.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Optional[str]: Decoded product name, or None if the read failed.
|
|
186
|
+
"""
|
|
187
|
+
buffer = read_pmdusb_uart(ser, PMDUSB_CMD.CMD_WELCOME, _WELCOME_REPLY_LEN)
|
|
188
|
+
if buffer is None:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
return buffer.decode("ascii").rstrip("\x00")
|
|
192
|
+
except UnicodeDecodeError:
|
|
193
|
+
logger.warning("Failed to decode welcome string: %r", buffer)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_pmdusb_id(ser: serial.Serial) -> Optional[Dict[str, int]]:
|
|
198
|
+
"""Read and validate vendor/product/firmware info from the device.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Optional[Dict[str, int]]: Dict with 'VendorId', 'ProductId', 'FwVersion',
|
|
202
|
+
or None if the read failed or the vendor/product ID didn't match.
|
|
203
|
+
"""
|
|
204
|
+
buffer = read_pmdusb_uart(ser, PMDUSB_CMD.CMD_READ_ID, sizeof(PMDUSBIdStruct))
|
|
205
|
+
if buffer is None:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
id_struct = PMDUSBIdStruct.from_buffer_copy(buffer)
|
|
209
|
+
if id_struct.VendorId != PMDUSB_VENDOR_ID or id_struct.ProductId != PMDUSB_PRODUCT_ID:
|
|
210
|
+
logger.warning("Unexpected vendor data: %s", id_struct)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"VendorId": id_struct.VendorId,
|
|
215
|
+
"ProductId": id_struct.ProductId,
|
|
216
|
+
"FwVersion": id_struct.FwVersion,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def read_pmdusb_sensors(ser: serial.Serial) -> Optional[PMDUSBSensorStruct]:
|
|
221
|
+
"""Read all sensor values from the device.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Optional[PMDUSBSensorStruct]: Decoded sensor structure, or None if the
|
|
225
|
+
read failed.
|
|
226
|
+
"""
|
|
227
|
+
buffer = read_pmdusb_uart(ser, PMDUSB_CMD.CMD_READ_SENSOR_VALUES, sizeof(PMDUSBSensorStruct))
|
|
228
|
+
if buffer is None:
|
|
229
|
+
logger.warning("Failed to read sensors from device")
|
|
230
|
+
return None
|
|
231
|
+
return PMDUSBSensorStruct.from_buffer_copy(buffer)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# --- Sensor translation ---
|
|
235
|
+
|
|
236
|
+
def translate_pmdusb_sensors(sensors: PMDUSBSensorStruct) -> Dict[str, Dict[str, float]]:
|
|
237
|
+
"""Convert a raw PMD-USB sensor struct into a dict of channel name -> reading.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Dict[str, Dict[str, float]]: Channel name -> {voltage, current, power}.
|
|
241
|
+
"""
|
|
242
|
+
result = {}
|
|
243
|
+
for idx, name in PMDUSB_CHANNELS.items():
|
|
244
|
+
channel = sensors.Channels[idx]
|
|
245
|
+
voltage = channel.Voltage * 0.01
|
|
246
|
+
current = channel.Current * 0.1
|
|
247
|
+
result[name] = {"voltage": voltage, "current": current, "power": voltage * current}
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def translate_pmdusb_aggregate_power(sensors: PMDUSBSensorStruct) -> Dict[str, float]:
|
|
252
|
+
"""Compute GPU/CPU/Total power from the per-channel readings, in watts.
|
|
253
|
+
|
|
254
|
+
Unlike PMD2/PMD2X, the PMD-USB firmware doesn't compute these aggregates
|
|
255
|
+
on-device -- they're derived client-side here, matching the official
|
|
256
|
+
GUI app: gpu = PCIE1 + PCIE2, cpu = EPS1 + EPS2, total = gpu + cpu.
|
|
257
|
+
"""
|
|
258
|
+
readings = translate_pmdusb_sensors(sensors)
|
|
259
|
+
channel_names = list(PMDUSB_CHANNELS.values())
|
|
260
|
+
|
|
261
|
+
gpu = sum(readings[channel_names[i]]["power"] for i in _GPU_CHANNELS)
|
|
262
|
+
cpu = sum(readings[channel_names[i]]["power"] for i in _CPU_CHANNELS)
|
|
263
|
+
|
|
264
|
+
return {"gpu": gpu, "cpu": cpu, "total": gpu + cpu}
|
pmd_pycore/version.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pmd-pycore
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: PMD PyCore - Python Interface for ElmorLabs PMD2/PMD2X/PMD-USB
|
|
5
|
+
Home-page: https://github.com/ElmorLabs-P1/pmd-pycore
|
|
6
|
+
Author: Pieter Plaisier
|
|
7
|
+
Author-email: Pieter Plaisier <pieter@hanweiconsulting.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/ElmorLabs-P1/pmd-pycore
|
|
10
|
+
Project-URL: Issues, https://github.com/ElmorLabs-P1/pmd-pycore/issues
|
|
11
|
+
Keywords: telemetry,hardware,pmd2,pmd-usb,elmorlabs,sensors
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: pyserial>=3.5
|
|
20
|
+
|
|
21
|
+
# pmd-pycore
|
|
22
|
+
|
|
23
|
+
Python interface for ElmorLabs PMD power measurement devices (PMD2, PMD2X, PMD-USB).
|
|
24
|
+
|
|
25
|
+
## Supported devices
|
|
26
|
+
|
|
27
|
+
| [PMD-USB](https://elmorlabs.com/product/elmorlabs-pmd-usb-power-measurement-device-with-usb/) *(discontinued)* | [PMD2](https://elmorlabs.com/product/elmorlabs-pmd2/) *(discontinued)* | [PMD2X](https://elmorlabs.com/product/elmorlabs-pmd2x-power-measurement-device-2x/) *(current)* |
|
|
28
|
+
|:---:|:---:|:---:|
|
|
29
|
+
| <img src="https://elmorlabs.com/wp-content/uploads/2022/05/with_case-scaled.jpg" width="220"> | <img src="https://elmorlabs.com/wp-content/uploads/2024/10/IMG_0216-scaled.jpg" width="220"> | <img src="https://elmorlabs.com/wp-content/uploads/2026/05/IMG_7900-scaled.jpg" width="220"> |
|
|
30
|
+
| 4 channels (PCIE1, PCIE2, EPS1, EPS2) over a CH340 USB-serial bridge. No ATX24/motherboard rail, no 12VHPWR. | 2x EPS, 3x PCIe, 1x ATX24, 1x 12VHPWR connector. STM32 USB-serial interface. Superseded by PMD2X. | Same rail set as PMD2, plus per-wire 12VHPWR monitoring and connector thermistors. |
|
|
31
|
+
|
|
32
|
+
PMD-USB was superseded by PMD2, which was in turn superseded by PMD2X -- the only one of the
|
|
33
|
+
three still sold. All three measure voltage/current/power on CPU, GPU, and (PMD2/PMD2X only)
|
|
34
|
+
motherboard power cables, with an OLED display and USB connection to a PC for logging.
|
|
35
|
+
|
|
36
|
+
## Pre-requisites
|
|
37
|
+
- Python >= 3.11, with pyserial (and numpy for the example scripts)
|
|
38
|
+
- PMD-USB uses a CH340 USB-serial chip. Linux has an in-kernel driver for it (binds
|
|
39
|
+
automatically, nothing to install). **Windows does not ship one** -- if the device shows up
|
|
40
|
+
in Device Manager but never gets a COM port, install the CH340/CH341 driver from the chip
|
|
41
|
+
manufacturer's site ([wch-ic.com](https://www.wch-ic.com/), search "CH340"/"CH341") and
|
|
42
|
+
replug. PMD2/PMD2X use an STM32 VCP instead and don't need a separate driver on either OS.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install -r requirements.txt
|
|
48
|
+
pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### PMD2 / PMD2X
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from pmd_pycore.core import (
|
|
57
|
+
get_pmd2_ports,
|
|
58
|
+
read_pmd2_welcome_string,
|
|
59
|
+
read_pmd2_device,
|
|
60
|
+
read_pmd2_uid,
|
|
61
|
+
read_pmd2_sensors,
|
|
62
|
+
translate_pmd2_sensor_struct,
|
|
63
|
+
)
|
|
64
|
+
import serial
|
|
65
|
+
|
|
66
|
+
port = get_pmd2_ports()[0]["port"]
|
|
67
|
+
ser = serial.Serial(port, 115200, timeout=1, rtscts=False, dsrdtr=False)
|
|
68
|
+
|
|
69
|
+
device_info = read_pmd2_device(ser)
|
|
70
|
+
sensors = read_pmd2_sensors(ser, device_info["ProductId"])
|
|
71
|
+
readings = translate_pmd2_sensor_struct(sensors, device_info["ProductId"])
|
|
72
|
+
print(readings["12V"]) # {'voltage': 12.0, 'current': 1.0, 'power': 12.0}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Currently implemented are the device's read-only commands:
|
|
76
|
+
`CMD_WELCOME` (`read_pmd2_welcome_string`), `CMD_READ_VENDOR_DATA` (`read_pmd2_device`),
|
|
77
|
+
`CMD_READ_UID` (`read_pmd2_uid`), and `CMD_READ_SENSOR_VALUES` (`read_pmd2_sensors`).
|
|
78
|
+
Writing/action commands (continuous TX, config read/write, reset, bootloader) are not
|
|
79
|
+
implemented.
|
|
80
|
+
|
|
81
|
+
See `examples/read_sensors.py` — a polling/averaging loop that mirrors the original sample script.
|
|
82
|
+
|
|
83
|
+
### PMD-USB
|
|
84
|
+
|
|
85
|
+
PMD-USB is a different device family -- a CH340 USB-serial bridge rather than PMD2/PMD2X's STM32
|
|
86
|
+
VCP, with its own command set and a simpler sensor set (PCIE1, PCIE2, EPS1, EPS2 only; no ATX24
|
|
87
|
+
rails, no 12VHPWR). There's no firmware source for it, so this was ported from and verified
|
|
88
|
+
against the reference implementations linked below plus a real device.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pmd_pycore.core import get_pmdusb_ports, read_pmdusb_id, read_pmdusb_sensors, translate_pmdusb_sensors
|
|
92
|
+
import serial
|
|
93
|
+
|
|
94
|
+
port = get_pmdusb_ports()[0]["port"]
|
|
95
|
+
ser = serial.Serial(port, 115200, timeout=1)
|
|
96
|
+
|
|
97
|
+
device_info = read_pmdusb_id(ser)
|
|
98
|
+
sensors = read_pmdusb_sensors(ser)
|
|
99
|
+
readings = translate_pmdusb_sensors(sensors)
|
|
100
|
+
print(readings["EPS1"]) # {'voltage': 12.0, 'current': 1.0, 'power': 12.0}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Implemented: `CMD_WELCOME` (`read_pmdusb_welcome_string`), `CMD_READ_ID` (`read_pmdusb_id`), and
|
|
104
|
+
`CMD_READ_SENSOR_VALUES` (`read_pmdusb_sensors`/`translate_pmdusb_sensors`). This command already
|
|
105
|
+
returns calibrated readings -- confirmed against the official PMD-USB GUI app's source, which
|
|
106
|
+
uses it directly with no client-side calibration math -- so `CMD_READ_ADC`'s raw path isn't
|
|
107
|
+
needed. `translate_pmdusb_aggregate_power` adds GPU/CPU/Total power (PCIE1+PCIE2 / EPS1+EPS2 /
|
|
108
|
+
sum), computed client-side the same way the official app does; PMD-USB's firmware doesn't
|
|
109
|
+
compute these on-device the way PMD2/PMD2X's does.
|
|
110
|
+
|
|
111
|
+
Note: PMD-USB has no per-unit UID/serial number anywhere -- not in the UART protocol (no command
|
|
112
|
+
for it), and not at the USB level (the CH340 chip's serial number field reads empty on the
|
|
113
|
+
device tested here). If you have more than one, there's no way to tell them apart besides which
|
|
114
|
+
COM port they land on.
|
|
115
|
+
|
|
116
|
+
See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for what's deliberately out of scope and why.
|
|
117
|
+
|
|
118
|
+
## Examples
|
|
119
|
+
|
|
120
|
+
- `examples/quickstart.py` — connect to the first device found, trying PMD2/PMD2X first and
|
|
121
|
+
falling back to PMD-USB, print its identity, and read sensor values once (for PMD2/PMD2X:
|
|
122
|
+
per-rail readings, firmware-computed aggregate power, chip health, and PMD2X's 12VHPWR
|
|
123
|
+
connector temperatures).
|
|
124
|
+
- `examples/read_sensors.py` — a polling/averaging loop, refreshing in place, for whichever
|
|
125
|
+
device family is connected (same PMD2/PMD2X-first-then-PMD-USB detection as `quickstart.py`).
|
|
126
|
+
|
|
127
|
+
## Status
|
|
128
|
+
|
|
129
|
+
This repository is under active development at
|
|
130
|
+
[ElmorLabs-P1/pmd-pycore](https://github.com/ElmorLabs-P1/pmd-pycore). It provides an installable
|
|
131
|
+
`pmd_pycore` package (serial discovery, UART command helpers, sensor structures, and
|
|
132
|
+
readable-unit translation for the supported devices), a test suite, and CI for tests and tagged
|
|
133
|
+
releases.
|
|
134
|
+
|
|
135
|
+
Tagged releases build an sdist/wheel and attach them to a GitHub Release. A PyPI-publish job is
|
|
136
|
+
wired into the release workflow but not yet active -- it needs PyPI Trusted Publishing configured
|
|
137
|
+
for this repo first (no credentials are stored in GitHub either way). Until that's set up,
|
|
138
|
+
install from the GitHub Release assets or `pip install -e .` from a checkout.
|
|
139
|
+
|
|
140
|
+
## Related repositories
|
|
141
|
+
|
|
142
|
+
- [PMDLogger](https://github.com/ElmorLabs/PMDLogger) — C# reference implementation for PMD-USB
|
|
143
|
+
(there's no public PMD-USB firmware source); `PMD_USB_Device.cs` has the command set and sensor
|
|
144
|
+
scaling used here.
|
|
145
|
+
- [PMD-USB](https://github.com/ElmorLabs/PMD-USB) — the official PMD-USB GUI app; `FormPMD.cs`
|
|
146
|
+
confirmed `CMD_READ_SENSOR_VALUES` is already calibrated and has the `CMD_READ_CONFIG` struct
|
|
147
|
+
layout.
|
|
148
|
+
- [pmd-usb-serial-interface-fast](https://github.com/ElmorLabs/pmd-usb-serial-interface-fast) /
|
|
149
|
+
[elmorlabs-pmd-usb-serial-interface](https://github.com/bjorntas/elmorlabs-pmd-usb-serial-interface) —
|
|
150
|
+
Python sample scripts for PMD-USB, cross-checked against PMDLogger and a real device.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pmd_pycore/__init__.py,sha256=_eMxohfq3Rre4-AtzyY1XK2EuCx7YIkO-Y5aKqmH6ps,400
|
|
2
|
+
pmd_pycore/version.py,sha256=OOGCofDv3M0OLYtA34LHnGR4QealaIU1hWXnl6tcCpM,66
|
|
3
|
+
pmd_pycore/core/__init__.py,sha256=683DROQK8mv9Me4e87LgLo_GMOkkoaKXE_Qr4neRMBc,2390
|
|
4
|
+
pmd_pycore/core/pmd2.py,sha256=8hti6tUW9YXDNTB7U3m5eQGcxQeF6mBfbXxbn36y0BY,13405
|
|
5
|
+
pmd_pycore/core/pmdusb.py,sha256=BqHQ045QnXlIKzTmR7YgFaf4Z-tT2cqR_xR-qK3Gjxg,8713
|
|
6
|
+
pmd_pycore-0.2.3.dist-info/METADATA,sha256=qeAG9zmZHTX8rZbLlzepHty_oPOYg9GU0nwbtdl3yn4,7501
|
|
7
|
+
pmd_pycore-0.2.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
pmd_pycore-0.2.3.dist-info/top_level.txt,sha256=_mxv8fuS_9QkcY0Xuy4t-Md2XJK_5Zi3WsiBSsS1g1Q,11
|
|
9
|
+
pmd_pycore-0.2.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pmd_pycore
|