diff --git a/setup.py b/setup.py index bab7e24..cf72010 100644 --- a/setup.py +++ b/setup.py @@ -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"), diff --git a/src/ppk2_api/ppk2_api.py b/src/ppk2_api/ppk2_api.py index c6e5236..c382db2 100644 --- a/src/ppk2_api/ppk2_api.py +++ b/src/ppk2_api/ppk2_api.py @@ -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.") diff --git a/tests/test_read_metadata.py b/tests/test_read_metadata.py new file mode 100644 index 0000000..752157a --- /dev/null +++ b/tests/test_read_metadata.py @@ -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