Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def read(*names, **kwargs):

setup(
name="ppk2-api",
version="0.9.2",
version="0.9.3",
description="API for Nordic Semiconductor's Power Profiler Kit II (PPK 2).",
url="https://github.com/IRNAS/ppk2-api-python",
packages=find_packages("src"),
Expand Down
32 changes: 22 additions & 10 deletions src/ppk2_api/ppk2_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,25 +166,37 @@ def _convert_source_voltage(self, mV: int) -> Tuple[int, int]:
return set_b_1, set_b_2

def _read_metadata(self) -> str:
"""Read metadata"""
# try to get metadata from device
for _ in range(0, 5):
"""Read metadata, accumulating chunks until the terminating 'END'.

Fixes IRNAS/ppk2-api-python issue #61: the PPK2 sends its calibration
metadata as a multi-line ASCII string ending in 'END', which the USB
serial driver may split across several reads (observed on macOS). The
previous version returned only the single chunk that contained 'END' and
discarded the earlier ones, so calibration fields that arrived first
(O, S, I, GS, GI, HW, ...) were silently dropped and left at their default
of 0 — adding a systematic low-range current offset (~1.8 uA) with no
error raised. Accumulating across reads keeps the full metadata intact.
"""
# allow more iterations than a single-read stream needs, since a split
# stream can take several reads to deliver the whole thing.
accumulated = ""
for _ in range(0, 10):
read = self.ser.read(self.ser.in_waiting)
time.sleep(0.1)

if not read:
continue # No data, try again
continue # No data yet, try again

# Try decoding the data
# Try decoding the data. Skip an undecodable chunk (e.g. residual
# bytes from a previous session) but keep what we've accumulated.
try:
metadata = read.decode("utf-8")
accumulated += read.decode("utf-8")
except UnicodeDecodeError:
# If decoding fails, try again in next iteration
continue

# Check if the metadata is valid (i.e., contains "END")
if "END" in metadata:
return metadata
# The full metadata is complete once we've seen the 'END' marker.
if "END" in accumulated:
return accumulated

# If we exit the loop, it means we couldn't get valid metadata
raise ValueError("Could not retrieve valid metadata from the device.")
Expand Down
70 changes: 70 additions & 0 deletions tests/test_read_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Hardware-free regression test for _read_metadata() dropping split calibration
chunks (IRNAS issue #61).

The PPK2 sends its calibration metadata as a multi-line ASCII string ending in
'END', which the USB serial driver may split across several reads (observed on
macOS). We feed a split stream through a fake serial port and assert the full
metadata survives and the parsed ADC zero-point offset is preserved.

Run: ./.venv/bin/python -m pytest tests/ -q
"""
from ppk2_api.ppk2_api import PPK2_API


def _make_api():
"""A PPK2_API instance without opening a serial port."""
api = PPK2_API.__new__(PPK2_API)
api.remainder = {"sequence": b"", "len": 0}
return api


class _FakeSerial:
"""Yields pre-canned read chunks to simulate a split metadata stream."""
def __init__(self, chunks):
self._chunks = list(chunks)

@property
def in_waiting(self):
return len(self._chunks[0]) if self._chunks else 0

def read(self, _n):
return self._chunks.pop(0) if self._chunks else b""


def _no_sleep(monkeypatch):
monkeypatch.setattr("ppk2_api.ppk2_api.time.sleep", lambda *a, **k: None)


def test_read_metadata_accumulates_split_chunks(monkeypatch):
_no_sleep(monkeypatch)
api = _make_api()
# 'END' arrives in the second read; the first read's fields must survive.
api.ser = _FakeSerial([b"Calibrated: 0\nR0: 1000\nO0: 166\n", b"S0: 2\nHW: 1\nEND\n"])
meta = api._read_metadata()
for field in ("Calibrated", "R0", "O0", "S0", "HW", "END"):
assert field in meta, f"{field} dropped from split metadata"


def test_split_metadata_preserves_offset(monkeypatch):
_no_sleep(monkeypatch)
api = _make_api()
# the modifiers dict _parse_metadata writes into (defaults are 0)
api.modifiers = {
"Calibrated": None,
"R": {str(i): 0.0 for i in range(5)},
"GS": {str(i): 0.0 for i in range(5)},
"GI": {str(i): 0.0 for i in range(5)},
"O": {str(i): 0.0 for i in range(5)},
"S": {str(i): 0.0 for i in range(5)},
"I": {str(i): 0.0 for i in range(5)},
"UG": {str(i): 0.0 for i in range(5)},
"HW": None,
"IA": None,
}
# O0 (the ADC zero-point offset) arrives in the FIRST read, END in the second.
api.ser = _FakeSerial([b"Calibrated: 1\nO0: 166.0\n", b"R0: 1000.0\nEND\n"])
api._parse_metadata(api._read_metadata())
# Under the old drop-chunks bug this stayed 0.0 -> ~1.8 uA low-range offset.
assert api.modifiers["O"]["0"] == 166.0
assert api.modifiers["R"]["0"] == 1000.0