From a91d8626477e8bd3fad1c2c60f3e4243a0988539 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 16 Mar 2026 00:22:43 -0700 Subject: [PATCH] fix(pm3py): platform-aware LED color mapping (Easy vs RDV4 have different physical layouts) --- pm3py/hw.py | 50 ++++++++++++++++++++++++++++++++---------------- tests/test_hw.py | 7 +++++-- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/pm3py/hw.py b/pm3py/hw.py index 52b41da..374b039 100644 --- a/pm3py/hw.py +++ b/pm3py/hw.py @@ -73,6 +73,13 @@ class HWCommands: speed: Cycle time in ms (default 500) count: Repeat count, 0=infinite (default 5) """ + # Fetch capabilities if not cached (needed for color mapping + PWM validation) + if self._is_rdv4 is None: + try: + await self.capabilities() + except PM3Error: + pass # unknown platform, default to Easy mapping + # Resolve LED mask if isinstance(led, int): led_mask = led & 0x0F @@ -118,30 +125,41 @@ class HWCommands: "count": count if action_code in (4, 6) else None, } - @staticmethod - def _parse_led_str(led: str) -> int: - """Parse LED string into bitmask. Accepts names, letters, or 'all'.""" - LED_MAP = { - "a": 0x01, "b": 0x02, "c": 0x04, "d": 0x08, - "green": 0x02, "orange": 0x01, "red": 0x04, "blue": 0x08, - "all": 0x0F, - } + def _parse_led_str(self, led: str) -> int: + """Parse LED string into bitmask. Color mapping depends on platform. + + Physical layout: + RDV4: A=orange, B=green, C=red, D=red2 + PM3 Easy: A=green, B=red, C=orange, D=blue + """ + rdv4 = self._is_rdv4 # None if unknown, assume Easy + # Letters always map directly + LETTER_MAP = {"a": 0x01, "b": 0x02, "c": 0x04, "d": 0x08, "all": 0x0F} + # Color names differ by platform + COLOR_MAP_RDV4 = {"green": 0x02, "orange": 0x01, "red": 0x04, "red2": 0x08, + "g": 0x02, "o": 0x01, "r": 0x04, "r2": 0x08} + COLOR_MAP_EASY = {"green": 0x01, "orange": 0x04, "red": 0x02, "blue": 0x08, + "g": 0x01, "o": 0x04, "r": 0x02, "b": 0x08} + color_map = COLOR_MAP_RDV4 if rdv4 else COLOR_MAP_EASY + mask = 0 for token in led.lower().replace(" ", "").split(","): - if token in LED_MAP: - mask |= LED_MAP[token] + if token in LETTER_MAP: + mask |= LETTER_MAP[token] + elif token in color_map: + mask |= color_map[token] + elif token == "blue" and rdv4: + raise ValueError("RDV4 has no blue LED") + elif token in ("red2", "r2") and not rdv4: + raise ValueError("PM3 Easy has no red2 LED, use 'blue' instead") else: - raise ValueError(f"Unknown LED: {token!r}. Use: {', '.join(LED_MAP.keys())}") + raise ValueError(f"Unknown LED: {token!r}. Use color names or letters a-d") return mask async def _check_pwm_capable(self, led_mask: int, action: str) -> None: """Raise if any LEDs in mask don't support PWM.""" - # Fetch capabilities if not cached if self._is_rdv4 is None: - try: - await self.capabilities() - except PM3Error: - return # can't validate, let it through + return # can't validate, let it through pwm_mask = self._PWM_LEDS_RDV4 if self._is_rdv4 else self._PWM_LEDS_EASY non_pwm = led_mask & ~pwm_mask diff --git a/tests/test_hw.py b/tests/test_hw.py index 9a3e7b2..873f196 100644 --- a/tests/test_hw.py +++ b/tests/test_hw.py @@ -51,9 +51,12 @@ def test_status(): def test_led_on_by_name(): transport = make_mock_transport() hw = HWCommands(transport) + hw._is_rdv4 = False # Easy: green=A=0x01 transport.send_ng.return_value = make_ng_response(Cmd.LED_CONTROL, 0, b"") result = asyncio.get_event_loop().run_until_complete(hw.led("green", on=True)) - transport.send_ng.assert_called_once() + payload = transport.send_ng.call_args[0][1] + led_mask = payload[0] + assert led_mask == 0x01 # green on Easy = A def test_led_pulse(): transport = make_mock_transport() @@ -82,7 +85,7 @@ def test_led_brightness(): result = asyncio.get_event_loop().run_until_complete(hw.led("green", brightness=50)) payload = transport.send_ng.call_args[0][1] led_mask, action, bright, _, _ = struct.unpack("