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 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
+ ]
@@ -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,5 @@
1
+ """
2
+ Version information for pmd-pycore
3
+ """
4
+
5
+ __version__ = "0.2.3"
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pmd_pycore