From e0221cfac482fe652604d7105b10f7d9d7e97b3c Mon Sep 17 00:00:00 2001 From: Sam Wong Date: Sun, 31 May 2026 14:27:31 +0000 Subject: [PATCH 1/2] feat: add Panasonic A/C protocol and Hong Kong/Macau model Introduce a generic PanasonicAcCommand that encodes the shared Panasonic A/C two-section framing (8-byte section 1 + variable section 2, LSB-first, 38 kHz), and a PanasonicAcHkCommand subclass that builds the full state and Quiet/Powerful short frames for Hong Kong/Macau models (CW-HU/HZ/SU/SUL). Co-authored-by: Cursor --- infrared_protocols/commands/panasonic_ac.py | 83 ++++++++ .../commands/panasonic_ac_hk.py | 140 +++++++++++++ tests/commands/test_panasonic_ac.py | 62 ++++++ tests/commands/test_panasonic_ac_hk.py | 189 ++++++++++++++++++ 4 files changed, 474 insertions(+) create mode 100644 infrared_protocols/commands/panasonic_ac.py create mode 100644 infrared_protocols/commands/panasonic_ac_hk.py create mode 100644 tests/commands/test_panasonic_ac.py create mode 100644 tests/commands/test_panasonic_ac_hk.py diff --git a/infrared_protocols/commands/panasonic_ac.py b/infrared_protocols/commands/panasonic_ac.py new file mode 100644 index 0000000..7b2db9b --- /dev/null +++ b/infrared_protocols/commands/panasonic_ac.py @@ -0,0 +1,83 @@ +"""Generic Panasonic air-conditioner IR protocol. + +Panasonic A/C remotes share a common framing: a state byte list split into two +sections (an 8-byte section 1 followed by a variable section 2), each encoded +LSB-first at 38 kHz with a fixed leader, per-bit mark/space, and a trailing +mark plus gap. Individual models differ only in the byte layout and checksum +range, so :class:`PanasonicAcCommand` encodes an already-built state and leaves +the layout to subclasses. +""" + +from typing import override + +from . import Command + +# Physical-layer timings, in microseconds (canonical Panasonic values). +HEADER_MARK = 3456 +HEADER_SPACE = 1728 +BIT_MARK = 432 +ZERO_SPACE = 432 +ONE_SPACE = 1296 +SECTION_GAP = 10000 +MESSAGE_GAP = 100000 + +MODULATION_HZ = 38000 + +# Section 1 is always the first 8 bytes; section 2 is the remainder. +SECTION1_LENGTH = 8 + + +def checksum(state: list[int], start: int, end: int) -> int: + """Sum bytes ``state[start..end]`` (inclusive) modulo 256.""" + total = 0 + for i in range(start, end + 1): + total = (total + state[i]) & 0xFF + return total + + +def _bits_lsb(byte: int) -> list[int]: + """Return the 8 bits of ``byte``, least-significant first.""" + return [(byte >> j) & 1 for j in range(8)] + + +def _section_timings(section: list[int], trailing_gap: int) -> list[int]: + """Encode one section (header + LSB-first bits + trailing mark/gap).""" + timings: list[int] = [HEADER_MARK, -HEADER_SPACE] + for byte in section: + for bit in _bits_lsb(byte): + timings.append(BIT_MARK) + timings.append(-(ONE_SPACE if bit else ZERO_SPACE)) + timings.append(BIT_MARK) + timings.append(-trailing_gap) + return timings + + +def state_to_timings(state: list[int]) -> list[int]: + """Convert a state byte list into signed microsecond timings. + + Positive values are pulse (carrier on) durations; negative values are space + (carrier off) durations. The state is split into section 1 (the first + :data:`SECTION1_LENGTH` bytes) and section 2 (the remainder). + """ + section1 = state[:SECTION1_LENGTH] + section2 = state[SECTION1_LENGTH:] + return [ + *_section_timings(section1, SECTION_GAP), + *_section_timings(section2, MESSAGE_GAP), + ] + + +class PanasonicAcCommand(Command): + """Generic Panasonic air-conditioner IR command.""" + + _state: list[int] + + def __init__(self, *, state: list[int], modulation: int = MODULATION_HZ) -> None: + """Wrap a pre-built Panasonic A/C state byte list.""" + super().__init__(modulation=modulation, repeat_count=0) + self._state = state + + @override + def get_raw_timings(self) -> list[int]: + """Get raw timings for the command.""" + return state_to_timings(self._state) diff --git a/infrared_protocols/commands/panasonic_ac_hk.py b/infrared_protocols/commands/panasonic_ac_hk.py new file mode 100644 index 0000000..6f5a3b8 --- /dev/null +++ b/infrared_protocols/commands/panasonic_ac_hk.py @@ -0,0 +1,140 @@ +"""Panasonic air-conditioner IR protocol for Hong Kong / Macau models. + +Reverse-engineered protocol for Panasonic air conditioners sold in Hong Kong +and Macau (CW-HU / CW-HZ / CW-SU / CW-SUL families). Builds a 27-byte full +state frame (power/mode/temperature/fan/swing/nanoeX) or a 16-byte +Quiet/Powerful short frame, then reuses the generic +:class:`~infrared_protocols.commands.panasonic_ac.PanasonicAcCommand` framing to +encode it to signed microsecond timings at 38 kHz. +""" + +from typing import Literal, Self + +from .panasonic_ac import PanasonicAcCommand, checksum + +MIN_TEMP = 16 +MAX_TEMP = 30 + +AcMode = Literal["auto", "dry", "cool", "heat"] +FanSpeed = Literal["auto", "low", "mediumLow", "medium", "mediumHigh", "high"] +SwingMode = Literal["auto", "fixed"] +ShortFrameKind = Literal["quiet", "powerful"] + +_MODE_NIBBLE: dict[AcMode, int] = { + "auto": 0x0, + "dry": 0x2, + "cool": 0x3, + "heat": 0x4, +} +_FAN_NIBBLE: dict[FanSpeed, int] = { + "auto": 0xA, + "low": 0x3, + "mediumLow": 0x4, + "medium": 0x5, + "mediumHigh": 0x6, + "high": 0x7, +} +_SWING_NIBBLE: dict[SwingMode, int] = {"auto": 0xF, "fixed": 0x5} + +_NANOEX_MASK = 0x04 + +_SHORT_PAYLOAD: dict[ShortFrameKind, list[int]] = { + "quiet": [0x80, 0x81, 0x33], + "powerful": [0x80, 0x86, 0x35], +} + + +def build_full_frame( + *, + off: bool = False, + mode: AcMode, + temp: float, + fan: FanSpeed, + swing: SwingMode, + nanoex: bool, +) -> list[int]: + """Build the 27-byte full state frame from semantic parameters. + + ``temp`` is in degrees Celsius; byte 14 stores ``round(temp * 2)`` so the + protocol's 0.5 °C step is preserved. + """ + state = [0] * 27 + for i, value in enumerate([0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06]): + state[i] = value + state[8] = 0x02 + state[9] = 0x20 + state[10] = 0xE0 + state[11] = 0x04 + state[12] = 0x00 + state[13] = (_MODE_NIBBLE[mode] << 4) | (0 if off else 1) + state[14] = round(temp * 2) + state[15] = 0x80 + state[16] = (_FAN_NIBBLE[fan] << 4) | _SWING_NIBBLE[swing] + state[17] = 0x0D + state[18] = 0x00 + state[19] = 0x0E + state[20] = 0xE0 + state[21] = 0x00 + state[22] = 0x00 + state[23] = 0x81 + state[24] = 0x00 + state[25] = 0x02 | (_NANOEX_MASK if nanoex else 0x00) + state[26] = checksum(state, 8, 25) + return state + + +def build_short_frame(kind: ShortFrameKind) -> list[int]: + """Build the 16-byte Quiet/Powerful toggle frame.""" + try: + payload = _SHORT_PAYLOAD[kind] + except KeyError: + raise ValueError(f"unknown short-frame kind: {kind!r}") from None + state = [ + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x00, + 0x00, + 0x06, + 0x02, + 0x20, + 0xE0, + 0x04, + *payload, + ] + state.append(checksum(state, 8, 14)) + return state + + +class PanasonicAcHkCommand(PanasonicAcCommand): + """Panasonic air-conditioner IR command for Hong Kong / Macau models.""" + + @classmethod + def full( + cls, + *, + off: bool = False, + mode: AcMode, + temp: float, + fan: FanSpeed, + swing: SwingMode, + nanoex: bool, + ) -> Self: + """Build a full state command (power/mode/temp/fan/swing/nanoeX).""" + return cls( + state=build_full_frame( + off=off, + mode=mode, + temp=temp, + fan=fan, + swing=swing, + nanoex=nanoex, + ) + ) + + @classmethod + def short(cls, *, kind: ShortFrameKind) -> Self: + """Build a Quiet/Powerful toggle command.""" + return cls(state=build_short_frame(kind)) diff --git a/tests/commands/test_panasonic_ac.py b/tests/commands/test_panasonic_ac.py new file mode 100644 index 0000000..1b631c7 --- /dev/null +++ b/tests/commands/test_panasonic_ac.py @@ -0,0 +1,62 @@ +"""Tests for the generic Panasonic A/C IR protocol.""" + +from infrared_protocols.commands.panasonic_ac import ( + MODULATION_HZ, + PanasonicAcCommand, + checksum, + state_to_timings, +) + +# A minimal two-section state: 8-byte section 1 + 4-byte section 2. +SAMPLE_STATE: list[int] = [ + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x00, + 0x00, + 0x06, + 0x02, + 0x20, + 0xE0, + 0x04, +] + + +def test_checksum_sum_mod_256() -> None: + """Test checksum sums the inclusive byte range modulo 256.""" + state = [0x00, 0xFF, 0x01, 0x05] + assert checksum(state, 1, 3) == (0xFF + 0x01 + 0x05) & 0xFF + + +def test_state_to_timings_leader() -> None: + """Test encoding starts with the Panasonic leader.""" + timings = state_to_timings(SAMPLE_STATE) + assert timings[:2] == [3456, -1728] + + +def test_state_to_timings_section_split() -> None: + """Test both sections are encoded with their own leader and gap. + + Each byte is 8 bits (2 timings/bit); each section adds a leader pair and a + trailing mark/gap pair. The first section gap is the inter-section gap and + the final gap is the message gap. + """ + timings = state_to_timings(SAMPLE_STATE) + bits_per_section1 = 8 * 8 * 2 + section1_len = 2 + bits_per_section1 + 2 + # Section 1 trailing gap is the inter-section gap. + assert timings[section1_len - 1] == -10000 + # Section 2 leader follows immediately. + assert timings[section1_len : section1_len + 2] == [3456, -1728] + # Final timing is the message gap. + assert timings[-1] == -100000 + + +def test_command_get_raw_timings_matches_helper() -> None: + """Test the command wrapper returns the same timings as the helper.""" + command = PanasonicAcCommand(state=SAMPLE_STATE) + assert command.modulation == MODULATION_HZ + assert command.repeat_count == 0 + assert command.get_raw_timings() == state_to_timings(SAMPLE_STATE) diff --git a/tests/commands/test_panasonic_ac_hk.py b/tests/commands/test_panasonic_ac_hk.py new file mode 100644 index 0000000..4bc9392 --- /dev/null +++ b/tests/commands/test_panasonic_ac_hk.py @@ -0,0 +1,189 @@ +"""Tests for the Hong Kong / Macau Panasonic A/C IR command.""" + +import pytest + +from infrared_protocols.commands.panasonic_ac import state_to_timings +from infrared_protocols.commands.panasonic_ac_hk import ( + PanasonicAcHkCommand, + build_full_frame, + build_short_frame, + checksum, +) + +# Full frame: cool 24 °C, fan auto, swing auto, power on, nanoeX off. +FULL_FRAME_COOL_24: list[int] = [ + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x00, + 0x00, + 0x06, + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x31, + 0x30, + 0x80, + 0xAF, + 0x0D, + 0x00, + 0x0E, + 0xE0, + 0x00, + 0x00, + 0x81, + 0x00, + 0x02, + 0x14, +] + +QUIET_SHORT_FRAME: list[int] = [ + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x00, + 0x00, + 0x06, + 0x02, + 0x20, + 0xE0, + 0x04, + 0x80, + 0x81, + 0x33, + 0x3A, +] + +POWERFUL_SHORT_FRAME: list[int] = [ + 0x02, + 0x20, + 0xE0, + 0x04, + 0x00, + 0x00, + 0x00, + 0x06, + 0x02, + 0x20, + 0xE0, + 0x04, + 0x80, + 0x86, + 0x35, + 0x41, +] + + +def test_checksum_frame2() -> None: + """Test checksum over frame 2 bytes matches the trailing checksum byte.""" + state = build_full_frame( + off=False, + mode="cool", + temp=24.0, + fan="auto", + swing="auto", + nanoex=False, + ) + assert checksum(state, 8, 25) == state[26] + + +def test_build_full_frame_cool_24() -> None: + """Test a known full state frame payload.""" + assert ( + build_full_frame( + off=False, + mode="cool", + temp=24.0, + fan="auto", + swing="auto", + nanoex=False, + ) + == FULL_FRAME_COOL_24 + ) + + +def test_build_full_frame_off_sets_power_nibble() -> None: + """Test the off flag clears the power bit in byte 13.""" + on = build_full_frame( + off=False, + mode="heat", + temp=20.0, + fan="low", + swing="fixed", + nanoex=False, + ) + off = build_full_frame( + off=True, + mode="heat", + temp=20.0, + fan="low", + swing="fixed", + nanoex=False, + ) + assert on[13] & 0x0F == 1 + assert off[13] & 0x0F == 0 + + +def test_build_full_frame_nanoex() -> None: + """Test nanoeX sets bit 2 in byte 25.""" + without = build_full_frame( + off=False, + mode="auto", + temp=26.0, + fan="medium", + swing="auto", + nanoex=False, + ) + with_nanoex = build_full_frame( + off=False, + mode="auto", + temp=26.0, + fan="medium", + swing="auto", + nanoex=True, + ) + assert without[25] == 0x02 + assert with_nanoex[25] == 0x06 + + +def test_build_short_frame_quiet() -> None: + """Test the Quiet short-frame payload and checksum.""" + assert build_short_frame("quiet") == QUIET_SHORT_FRAME + + +def test_build_short_frame_powerful() -> None: + """Test the Powerful short-frame payload and checksum.""" + assert build_short_frame("powerful") == POWERFUL_SHORT_FRAME + + +def test_build_short_frame_rejects_unknown_kind() -> None: + """Test an unknown short-frame kind raises a descriptive ValueError.""" + with pytest.raises(ValueError, match="unknown short-frame kind: 'bogus'"): + build_short_frame("bogus") # type: ignore[arg-type] + + +def test_command_full_get_raw_timings() -> None: + """Test the full command encodes via the generic framing.""" + command = PanasonicAcHkCommand.full( + off=False, + mode="cool", + temp=24.0, + fan="auto", + swing="auto", + nanoex=False, + ) + assert command.modulation == 38000 + assert command.repeat_count == 0 + assert command.get_raw_timings() == state_to_timings(FULL_FRAME_COOL_24) + + +def test_command_short_get_raw_timings() -> None: + """Test the short command encodes via the generic framing.""" + command = PanasonicAcHkCommand.short(kind="quiet") + assert command.get_raw_timings() == state_to_timings(QUIET_SHORT_FRAME) From 18170bf31839a639747b07a0fd7625590008dcc6 Mon Sep 17 00:00:00 2001 From: Sam Wong Date: Tue, 2 Jun 2026 20:19:50 +0000 Subject: [PATCH 2/2] refactor: Migrate PanasonicAcCommand to be based on KaseikyoCommandinstead, and add multi-frame data support to it. Remove PanasonicAcHkCommand - Modified KaseikyoCommand to accept `data` as either a single `bytes` or a `list[bytes]`, allowing for multi-frame IR commands. - Enhanced the initialization docstring to clarify the usage of `data`. - Removed the PanasonicAcHkCommand and its associated tests, consolidating functionality into the PanasonicAcCommand. - Updated PanasonicAcCommand to handle the new multi-frame structure and maintain compatibility with existing features. --- infrared_protocols/commands/kaseikyo.py | 60 +++-- infrared_protocols/commands/panasonic_ac.py | 227 +++++++++++++----- .../commands/panasonic_ac_hk.py | 140 ----------- tests/commands/test_panasonic_ac.py | 144 +++++++---- tests/commands/test_panasonic_ac_hk.py | 189 --------------- 5 files changed, 297 insertions(+), 463 deletions(-) delete mode 100644 infrared_protocols/commands/panasonic_ac_hk.py delete mode 100644 tests/commands/test_panasonic_ac_hk.py diff --git a/infrared_protocols/commands/kaseikyo.py b/infrared_protocols/commands/kaseikyo.py index 550a868..f970af5 100644 --- a/infrared_protocols/commands/kaseikyo.py +++ b/infrared_protocols/commands/kaseikyo.py @@ -10,7 +10,7 @@ class KaseikyoCommand(Command): """Kaseikyo format IR command.""" address: int - data: bytes + data: bytes | list[bytes] error_correction: Callable[[bytes], bytes] | None base_unit: float @@ -18,13 +18,19 @@ def __init__( self, *, address: int, - data: bytes, + data: bytes | list[bytes], error_correction: Callable[[bytes], bytes] | None = None, modulation: int = 38000, burst_pulse: int = 16, repeat_count: int = 0, ) -> None: - """Initialize the Kaseikyo IR command.""" + """Initialize the Kaseikyo IR command. + + ``data`` is the payload following the 16-bit address. Pass a single + ``bytes`` for a single-frame command, or a ``list[bytes]`` to send + several frames (each sharing the same address) as one multi-frame + message separated by an inter-frame gap. + """ super().__init__(modulation=modulation, repeat_count=repeat_count) self.address = address self.data = data @@ -55,35 +61,43 @@ def get_raw_timings(self) -> list[int]: one_low = round(3 * self.base_unit) frame_time = 130000 trailer_min = 8000 + multi_frame_gap = 10000 repeat_frame_gap = max( frame_time - (leader_high + repeat_low + bit_high), trailer_min ) - timings = [leader_high, -leader_low] - parity = self.address & 0xFFFF parity ^= parity >> 8 parity ^= parity >> 4 parity &= 0x0F - data_bytes = [ - self.address & 0xFF, - (self.address >> 8) & 0xFF, - (self.data[0] & 0xF0) | parity, - *self.data[1:], - ] - if self.error_correction: - data_bytes.extend(self.error_correction(bytes(data_bytes))) - - for byte in data_bytes: - for _ in range(8): - bit = byte & 1 - timings.append(bit_high) - timings.append(-one_low if bit else -zero_low) - byte >>= 1 - - # End pulse - timings.append(bit_high) + frames = [self.data] if isinstance(self.data, bytes) else self.data + + timings: list[int] = [] + for i, frame in enumerate(frames): + if i != 0: + # Inter-frame gap for multi-frame commands + timings.append(-multi_frame_gap) + timings.extend([leader_high, -leader_low]) + + data_bytes = [ + self.address & 0xFF, + (self.address >> 8) & 0xFF, + (frame[0] & 0xF0) | parity, + *frame[1:], + ] + if self.error_correction: + data_bytes.extend(self.error_correction(bytes(data_bytes))) + + for byte in data_bytes: + for _ in range(8): + bit = byte & 1 + timings.append(bit_high) + timings.append(-one_low if bit else -zero_low) + byte >>= 1 + + # End pulse + timings.append(bit_high) # Add repeat codes if requested gap = max(frame_time - sum(abs(t) for t in timings), trailer_min) diff --git a/infrared_protocols/commands/panasonic_ac.py b/infrared_protocols/commands/panasonic_ac.py index 7b2db9b..c03d2db 100644 --- a/infrared_protocols/commands/panasonic_ac.py +++ b/infrared_protocols/commands/panasonic_ac.py @@ -1,83 +1,186 @@ -"""Generic Panasonic air-conditioner IR protocol. - -Panasonic A/C remotes share a common framing: a state byte list split into two -sections (an 8-byte section 1 followed by a variable section 2), each encoded -LSB-first at 38 kHz with a fixed leader, per-bit mark/space, and a trailing -mark plus gap. Individual models differ only in the byte layout and checksum -range, so :class:`PanasonicAcCommand` encodes an already-built state and leaves -the layout to subclasses. +"""Panasonic air-conditioner IR protocol. + +Panasonic A/C remotes use the Kaseikyo (AEHA) format with the Panasonic vendor +address ``0x2002``. The state is sent as two Kaseikyo frames (a fixed 8-byte +preamble frame followed by a 19-byte payload frame) separated by an inter-frame +gap, so this module builds the state bytes and delegates the physical-layer +encoding to :class:`~infrared_protocols.commands.kaseikyo.KaseikyoCommand`. + +The encoder is intentionally generic rather than tied to one regional model: it +exposes the full field set (power, mode, temperature, fan, two swing axes and +nanoeX) so callers can drive whichever combination their unit supports. """ -from typing import override +from enum import Enum, IntEnum -from . import Command +from .kaseikyo import KaseikyoCommand -# Physical-layer timings, in microseconds (canonical Panasonic values). -HEADER_MARK = 3456 -HEADER_SPACE = 1728 -BIT_MARK = 432 -ZERO_SPACE = 432 -ONE_SPACE = 1296 -SECTION_GAP = 10000 -MESSAGE_GAP = 100000 +PANASONIC_AC_ADDRESS = 0x2002 -MODULATION_HZ = 38000 +MIN_TEMP = 16 +MAX_TEMP = 30 -# Section 1 is always the first 8 bytes; section 2 is the remainder. -SECTION1_LENGTH = 8 +# Fixed "magic" bytes that frame the state; they must be emitted verbatim. +_FRAME1 = [0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06] +_FRAME2_MAGIC = [0x02, 0x20, 0xE0, 0x04] +_NANOEX_MASK = 0x04 +_FEATURE_BASE = 0x02 +_SHORT_FRAME_MARKER = 0x80 -def checksum(state: list[int], start: int, end: int) -> int: - """Sum bytes ``state[start..end]`` (inclusive) modulo 256.""" - total = 0 - for i in range(start, end + 1): - total = (total + state[i]) & 0xFF - return total +class PanasonicAcMode(IntEnum): + """Operation mode, stored in the high nibble of byte 13.""" + + AUTO = 0x0 + DRY = 0x2 + COOL = 0x3 + HEAT = 0x4 -def _bits_lsb(byte: int) -> list[int]: - """Return the 8 bits of ``byte``, least-significant first.""" - return [(byte >> j) & 1 for j in range(8)] +class PanasonicAcFanSpeed(IntEnum): + """Fan speed, stored in the high nibble of byte 16.""" -def _section_timings(section: list[int], trailing_gap: int) -> list[int]: - """Encode one section (header + LSB-first bits + trailing mark/gap).""" - timings: list[int] = [HEADER_MARK, -HEADER_SPACE] - for byte in section: - for bit in _bits_lsb(byte): - timings.append(BIT_MARK) - timings.append(-(ONE_SPACE if bit else ZERO_SPACE)) - timings.append(BIT_MARK) - timings.append(-trailing_gap) - return timings + AUTO = 0xA + LOW = 0x3 + MEDIUM_LOW = 0x4 + MEDIUM = 0x5 + MEDIUM_HIGH = 0x6 + HIGH = 0x7 -def state_to_timings(state: list[int]) -> list[int]: - """Convert a state byte list into signed microsecond timings. +class PanasonicAcSwingAxis1(IntEnum): + """Swing positions for protocol slot 1 (low nibble of byte 16). - Positive values are pulse (carrier on) durations; negative values are space - (carrier off) durations. The state is split into section 1 (the first - :data:`SECTION1_LENGTH` bytes) and section 2 (the remainder). + The physical louver this drives depends on the unit: on window units it is + the (single) horizontal louver, while on split units it is the vertical + louver. The position names follow the IRremoteESP8266 vertical-swing labels + and are axis-relative, so the caller maps them to the real direction. """ - section1 = state[:SECTION1_LENGTH] - section2 = state[SECTION1_LENGTH:] - return [ - *_section_timings(section1, SECTION_GAP), - *_section_timings(section2, MESSAGE_GAP), - ] + AUTO = 0xF + HIGHEST = 0x1 + HIGH = 0x2 + MIDDLE = 0x3 + LOW = 0x4 + LOWEST = 0x5 + + +class PanasonicAcSwingAxis2(IntEnum): + """Swing positions for protocol slot 2 (low nibble of byte 17). + + On split units this is the horizontal louver; many units (e.g. single-louver + window units) leave it at :attr:`AUTO`. The position names follow the + IRremoteESP8266 horizontal-swing labels and are axis-relative. + """ -class PanasonicAcCommand(Command): - """Generic Panasonic air-conditioner IR command.""" + AUTO = 0xD + MIDDLE = 0x6 + FULL_LEFT = 0x9 + LEFT = 0xA + RIGHT = 0xB + FULL_RIGHT = 0xC - _state: list[int] - def __init__(self, *, state: list[int], modulation: int = MODULATION_HZ) -> None: - """Wrap a pre-built Panasonic A/C state byte list.""" - super().__init__(modulation=modulation, repeat_count=0) - self._state = state +class PanasonicAcToggle(Enum): + """Short-frame toggle command, holding its two payload bytes (13, 14).""" - @override - def get_raw_timings(self) -> list[int]: - """Get raw timings for the command.""" - return state_to_timings(self._state) + QUIET = (0x81, 0x33) + POWERFUL = (0x86, 0x35) + + +def _checksum(state: list[int], start: int, end: int) -> int: + """Sum bytes ``state[start..end]`` (inclusive) modulo 256.""" + return sum(state[start : end + 1]) & 0xFF + + +def _to_frames(state: list[int]) -> list[bytes]: + """Split a full state byte list into Kaseikyo per-frame payloads. + + Each frame's first two bytes are the Kaseikyo address (``0x2002``), so the + payload passed to :class:`KaseikyoCommand` is the state with those two + address bytes dropped from each section. + """ + return [bytes(state[2:8]), bytes(state[10:])] + + +class PanasonicAcCommand(KaseikyoCommand): + """Panasonic air-conditioner full-state IR command.""" + + def __init__( + self, + *, + mode: PanasonicAcMode, + temperature: float, + fan: PanasonicAcFanSpeed = PanasonicAcFanSpeed.AUTO, + power: bool = True, + swing_axis1: PanasonicAcSwingAxis1 = PanasonicAcSwingAxis1.AUTO, + swing_axis2: PanasonicAcSwingAxis2 = PanasonicAcSwingAxis2.AUTO, + nanoex: bool = False, + modulation: int = 38000, + ) -> None: + """Build a full Panasonic A/C state command. + + ``temperature`` is in degrees Celsius and is stored as ``round(°C × 2)`` + in byte 14, preserving the protocol's 0.5 °C step. It must be within + :data:`MIN_TEMP`..:data:`MAX_TEMP`. + """ + if not MIN_TEMP <= temperature <= MAX_TEMP: + raise ValueError( + f"temperature {temperature} out of range {MIN_TEMP}..{MAX_TEMP}" + ) + + state = [ + *_FRAME1, + *_FRAME2_MAGIC, + 0x00, + (mode << 4) | (0x01 if power else 0x00), + round(temperature * 2), + 0x80, + (fan << 4) | swing_axis1, + swing_axis2, + 0x00, + 0x0E, + 0xE0, + 0x00, + 0x00, + 0x81, + 0x00, + _FEATURE_BASE | (_NANOEX_MASK if nanoex else 0x00), + ] + state.append(_checksum(state, 8, 25)) + + super().__init__( + address=PANASONIC_AC_ADDRESS, + data=_to_frames(state), + modulation=modulation, + ) + + +class PanasonicAcToggleCommand(KaseikyoCommand): + """Panasonic air-conditioner short toggle command (Quiet / Powerful).""" + + def __init__( + self, + *, + toggle: PanasonicAcToggle, + modulation: int = 38000, + ) -> None: + """Build a short Quiet/Powerful toggle command. + + These are dedicated 16-byte frames that carry no mode/temperature/fan/ + swing state; the unit keeps whatever it was already running. + """ + state = [ + *_FRAME1, + *_FRAME2_MAGIC, + _SHORT_FRAME_MARKER, + *toggle.value, + ] + state.append(_checksum(state, 8, 14)) + + super().__init__( + address=PANASONIC_AC_ADDRESS, + data=_to_frames(state), + modulation=modulation, + ) diff --git a/infrared_protocols/commands/panasonic_ac_hk.py b/infrared_protocols/commands/panasonic_ac_hk.py deleted file mode 100644 index 6f5a3b8..0000000 --- a/infrared_protocols/commands/panasonic_ac_hk.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Panasonic air-conditioner IR protocol for Hong Kong / Macau models. - -Reverse-engineered protocol for Panasonic air conditioners sold in Hong Kong -and Macau (CW-HU / CW-HZ / CW-SU / CW-SUL families). Builds a 27-byte full -state frame (power/mode/temperature/fan/swing/nanoeX) or a 16-byte -Quiet/Powerful short frame, then reuses the generic -:class:`~infrared_protocols.commands.panasonic_ac.PanasonicAcCommand` framing to -encode it to signed microsecond timings at 38 kHz. -""" - -from typing import Literal, Self - -from .panasonic_ac import PanasonicAcCommand, checksum - -MIN_TEMP = 16 -MAX_TEMP = 30 - -AcMode = Literal["auto", "dry", "cool", "heat"] -FanSpeed = Literal["auto", "low", "mediumLow", "medium", "mediumHigh", "high"] -SwingMode = Literal["auto", "fixed"] -ShortFrameKind = Literal["quiet", "powerful"] - -_MODE_NIBBLE: dict[AcMode, int] = { - "auto": 0x0, - "dry": 0x2, - "cool": 0x3, - "heat": 0x4, -} -_FAN_NIBBLE: dict[FanSpeed, int] = { - "auto": 0xA, - "low": 0x3, - "mediumLow": 0x4, - "medium": 0x5, - "mediumHigh": 0x6, - "high": 0x7, -} -_SWING_NIBBLE: dict[SwingMode, int] = {"auto": 0xF, "fixed": 0x5} - -_NANOEX_MASK = 0x04 - -_SHORT_PAYLOAD: dict[ShortFrameKind, list[int]] = { - "quiet": [0x80, 0x81, 0x33], - "powerful": [0x80, 0x86, 0x35], -} - - -def build_full_frame( - *, - off: bool = False, - mode: AcMode, - temp: float, - fan: FanSpeed, - swing: SwingMode, - nanoex: bool, -) -> list[int]: - """Build the 27-byte full state frame from semantic parameters. - - ``temp`` is in degrees Celsius; byte 14 stores ``round(temp * 2)`` so the - protocol's 0.5 °C step is preserved. - """ - state = [0] * 27 - for i, value in enumerate([0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06]): - state[i] = value - state[8] = 0x02 - state[9] = 0x20 - state[10] = 0xE0 - state[11] = 0x04 - state[12] = 0x00 - state[13] = (_MODE_NIBBLE[mode] << 4) | (0 if off else 1) - state[14] = round(temp * 2) - state[15] = 0x80 - state[16] = (_FAN_NIBBLE[fan] << 4) | _SWING_NIBBLE[swing] - state[17] = 0x0D - state[18] = 0x00 - state[19] = 0x0E - state[20] = 0xE0 - state[21] = 0x00 - state[22] = 0x00 - state[23] = 0x81 - state[24] = 0x00 - state[25] = 0x02 | (_NANOEX_MASK if nanoex else 0x00) - state[26] = checksum(state, 8, 25) - return state - - -def build_short_frame(kind: ShortFrameKind) -> list[int]: - """Build the 16-byte Quiet/Powerful toggle frame.""" - try: - payload = _SHORT_PAYLOAD[kind] - except KeyError: - raise ValueError(f"unknown short-frame kind: {kind!r}") from None - state = [ - 0x02, - 0x20, - 0xE0, - 0x04, - 0x00, - 0x00, - 0x00, - 0x06, - 0x02, - 0x20, - 0xE0, - 0x04, - *payload, - ] - state.append(checksum(state, 8, 14)) - return state - - -class PanasonicAcHkCommand(PanasonicAcCommand): - """Panasonic air-conditioner IR command for Hong Kong / Macau models.""" - - @classmethod - def full( - cls, - *, - off: bool = False, - mode: AcMode, - temp: float, - fan: FanSpeed, - swing: SwingMode, - nanoex: bool, - ) -> Self: - """Build a full state command (power/mode/temp/fan/swing/nanoeX).""" - return cls( - state=build_full_frame( - off=off, - mode=mode, - temp=temp, - fan=fan, - swing=swing, - nanoex=nanoex, - ) - ) - - @classmethod - def short(cls, *, kind: ShortFrameKind) -> Self: - """Build a Quiet/Powerful toggle command.""" - return cls(state=build_short_frame(kind)) diff --git a/tests/commands/test_panasonic_ac.py b/tests/commands/test_panasonic_ac.py index 1b631c7..1a2de95 100644 --- a/tests/commands/test_panasonic_ac.py +++ b/tests/commands/test_panasonic_ac.py @@ -1,62 +1,108 @@ -"""Tests for the generic Panasonic A/C IR protocol.""" +"""Tests for the Panasonic air-conditioner IR commands.""" + +import pytest from infrared_protocols.commands.panasonic_ac import ( - MODULATION_HZ, PanasonicAcCommand, - checksum, - state_to_timings, + PanasonicAcFanSpeed, + PanasonicAcMode, + PanasonicAcSwingAxis1, + PanasonicAcSwingAxis2, + PanasonicAcToggle, + PanasonicAcToggleCommand, ) -# A minimal two-section state: 8-byte section 1 + 4-byte section 2. -SAMPLE_STATE: list[int] = [ - 0x02, - 0x20, - 0xE0, - 0x04, - 0x00, - 0x00, - 0x00, - 0x06, - 0x02, - 0x20, - 0xE0, - 0x04, +# Known-good frames from the reverse-engineered protocol spec. Each frame +# includes its two leading Kaseikyo address bytes (0x02 0x20). +FULL_COOL_24_FRAMES: list[list[int]] = [ + [0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06], + # fmt: off + [ + 0x02, 0x20, 0xE0, 0x04, 0x00, 0x31, 0x30, 0x80, 0xAF, 0x0D, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x02, 0x14, + ], + # fmt: on +] +QUIET_FRAMES: list[list[int]] = [ + [0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06], + [0x02, 0x20, 0xE0, 0x04, 0x80, 0x81, 0x33, 0x3A], ] -def test_checksum_sum_mod_256() -> None: - """Test checksum sums the inclusive byte range modulo 256.""" - state = [0x00, 0xFF, 0x01, 0x05] - assert checksum(state, 1, 3) == (0xFF + 0x01 + 0x05) & 0xFF - +def _decode_frames(timings: list[int]) -> list[list[int]]: + """Decode raw Kaseikyo timings back into per-frame byte lists. -def test_state_to_timings_leader() -> None: - """Test encoding starts with the Panasonic leader.""" - timings = state_to_timings(SAMPLE_STATE) - assert timings[:2] == [3456, -1728] + Splits on the inter-frame gap, drops each frame's leader and trailing end + pulse, then packs the LSB-first bits into bytes. A bit is a 1 when its space + is clearly longer than the bit mark, so decoding is independent of the exact + base-unit timings. The decoded bytes include each frame's address bytes. + """ + frames: list[list[int]] = [[]] + for value in timings: + if value == -10000: + frames.append([]) + else: + frames[-1].append(value) + decoded: list[list[int]] = [] + for frame in frames: + body = frame[2:-1] + mark = body[0] + bits = [1 if -body[i + 1] > 2 * mark else 0 for i in range(0, len(body), 2)] + decoded.append( + [sum(bits[i + k] << k for k in range(8)) for i in range(0, len(bits), 8)] + ) + return decoded -def test_state_to_timings_section_split() -> None: - """Test both sections are encoded with their own leader and gap. - Each byte is 8 bits (2 timings/bit); each section adds a leader pair and a - trailing mark/gap pair. The first section gap is the inter-section gap and - the final gap is the message gap. - """ - timings = state_to_timings(SAMPLE_STATE) - bits_per_section1 = 8 * 8 * 2 - section1_len = 2 + bits_per_section1 + 2 - # Section 1 trailing gap is the inter-section gap. - assert timings[section1_len - 1] == -10000 - # Section 2 leader follows immediately. - assert timings[section1_len : section1_len + 2] == [3456, -1728] - # Final timing is the message gap. - assert timings[-1] == -100000 - - -def test_command_get_raw_timings_matches_helper() -> None: - """Test the command wrapper returns the same timings as the helper.""" - command = PanasonicAcCommand(state=SAMPLE_STATE) - assert command.modulation == MODULATION_HZ +def test_full_command_frames() -> None: + """Test the full state command encodes to the expected frame bytes.""" + command = PanasonicAcCommand(mode=PanasonicAcMode.COOL, temperature=24.0) + assert _decode_frames(command.get_raw_timings()) == FULL_COOL_24_FRAMES + assert command.modulation == 38000 assert command.repeat_count == 0 - assert command.get_raw_timings() == state_to_timings(SAMPLE_STATE) + + +def test_toggle_command_frames() -> None: + """Test the Quiet toggle command encodes to the expected frame bytes.""" + command = PanasonicAcToggleCommand(toggle=PanasonicAcToggle.QUIET) + assert _decode_frames(command.get_raw_timings()) == QUIET_FRAMES + + +def test_power_off_clears_power_bit() -> None: + """Test power=False clears bit 0 of byte 13.""" + on = PanasonicAcCommand(mode=PanasonicAcMode.HEAT, temperature=20.0) + off = PanasonicAcCommand(mode=PanasonicAcMode.HEAT, temperature=20.0, power=False) + assert _decode_frames(on.get_raw_timings())[1][5] & 0x0F == 1 + assert _decode_frames(off.get_raw_timings())[1][5] & 0x0F == 0 + + +def test_nanoex_sets_feature_bit() -> None: + """Test nanoeX flips bit 0x04 of byte 25.""" + without = PanasonicAcCommand(mode=PanasonicAcMode.AUTO, temperature=26.0) + with_nanoex = PanasonicAcCommand( + mode=PanasonicAcMode.AUTO, temperature=26.0, nanoex=True + ) + assert _decode_frames(without.get_raw_timings())[1][17] == 0x02 + assert _decode_frames(with_nanoex.get_raw_timings())[1][17] == 0x06 + + +def test_swing_axes_land_in_expected_nibbles() -> None: + """Test the two swing axes encode into bytes 16 and 17.""" + command = PanasonicAcCommand( + mode=PanasonicAcMode.COOL, + temperature=24.0, + fan=PanasonicAcFanSpeed.HIGH, + swing_axis1=PanasonicAcSwingAxis1.LOWEST, + swing_axis2=PanasonicAcSwingAxis2.FULL_LEFT, + ) + frame2 = _decode_frames(command.get_raw_timings())[1] + assert frame2[8] == (PanasonicAcFanSpeed.HIGH << 4) | PanasonicAcSwingAxis1.LOWEST + assert frame2[9] == PanasonicAcSwingAxis2.FULL_LEFT + + +@pytest.mark.parametrize("temperature", [15.0, 30.5, 31.0]) +def test_temperature_out_of_range_raises(temperature: float) -> None: + """Test an out-of-range temperature raises ValueError.""" + with pytest.raises(ValueError, match="out of range"): + PanasonicAcCommand(mode=PanasonicAcMode.COOL, temperature=temperature) diff --git a/tests/commands/test_panasonic_ac_hk.py b/tests/commands/test_panasonic_ac_hk.py deleted file mode 100644 index 4bc9392..0000000 --- a/tests/commands/test_panasonic_ac_hk.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Tests for the Hong Kong / Macau Panasonic A/C IR command.""" - -import pytest - -from infrared_protocols.commands.panasonic_ac import state_to_timings -from infrared_protocols.commands.panasonic_ac_hk import ( - PanasonicAcHkCommand, - build_full_frame, - build_short_frame, - checksum, -) - -# Full frame: cool 24 °C, fan auto, swing auto, power on, nanoeX off. -FULL_FRAME_COOL_24: list[int] = [ - 0x02, - 0x20, - 0xE0, - 0x04, - 0x00, - 0x00, - 0x00, - 0x06, - 0x02, - 0x20, - 0xE0, - 0x04, - 0x00, - 0x31, - 0x30, - 0x80, - 0xAF, - 0x0D, - 0x00, - 0x0E, - 0xE0, - 0x00, - 0x00, - 0x81, - 0x00, - 0x02, - 0x14, -] - -QUIET_SHORT_FRAME: list[int] = [ - 0x02, - 0x20, - 0xE0, - 0x04, - 0x00, - 0x00, - 0x00, - 0x06, - 0x02, - 0x20, - 0xE0, - 0x04, - 0x80, - 0x81, - 0x33, - 0x3A, -] - -POWERFUL_SHORT_FRAME: list[int] = [ - 0x02, - 0x20, - 0xE0, - 0x04, - 0x00, - 0x00, - 0x00, - 0x06, - 0x02, - 0x20, - 0xE0, - 0x04, - 0x80, - 0x86, - 0x35, - 0x41, -] - - -def test_checksum_frame2() -> None: - """Test checksum over frame 2 bytes matches the trailing checksum byte.""" - state = build_full_frame( - off=False, - mode="cool", - temp=24.0, - fan="auto", - swing="auto", - nanoex=False, - ) - assert checksum(state, 8, 25) == state[26] - - -def test_build_full_frame_cool_24() -> None: - """Test a known full state frame payload.""" - assert ( - build_full_frame( - off=False, - mode="cool", - temp=24.0, - fan="auto", - swing="auto", - nanoex=False, - ) - == FULL_FRAME_COOL_24 - ) - - -def test_build_full_frame_off_sets_power_nibble() -> None: - """Test the off flag clears the power bit in byte 13.""" - on = build_full_frame( - off=False, - mode="heat", - temp=20.0, - fan="low", - swing="fixed", - nanoex=False, - ) - off = build_full_frame( - off=True, - mode="heat", - temp=20.0, - fan="low", - swing="fixed", - nanoex=False, - ) - assert on[13] & 0x0F == 1 - assert off[13] & 0x0F == 0 - - -def test_build_full_frame_nanoex() -> None: - """Test nanoeX sets bit 2 in byte 25.""" - without = build_full_frame( - off=False, - mode="auto", - temp=26.0, - fan="medium", - swing="auto", - nanoex=False, - ) - with_nanoex = build_full_frame( - off=False, - mode="auto", - temp=26.0, - fan="medium", - swing="auto", - nanoex=True, - ) - assert without[25] == 0x02 - assert with_nanoex[25] == 0x06 - - -def test_build_short_frame_quiet() -> None: - """Test the Quiet short-frame payload and checksum.""" - assert build_short_frame("quiet") == QUIET_SHORT_FRAME - - -def test_build_short_frame_powerful() -> None: - """Test the Powerful short-frame payload and checksum.""" - assert build_short_frame("powerful") == POWERFUL_SHORT_FRAME - - -def test_build_short_frame_rejects_unknown_kind() -> None: - """Test an unknown short-frame kind raises a descriptive ValueError.""" - with pytest.raises(ValueError, match="unknown short-frame kind: 'bogus'"): - build_short_frame("bogus") # type: ignore[arg-type] - - -def test_command_full_get_raw_timings() -> None: - """Test the full command encodes via the generic framing.""" - command = PanasonicAcHkCommand.full( - off=False, - mode="cool", - temp=24.0, - fan="auto", - swing="auto", - nanoex=False, - ) - assert command.modulation == 38000 - assert command.repeat_count == 0 - assert command.get_raw_timings() == state_to_timings(FULL_FRAME_COOL_24) - - -def test_command_short_get_raw_timings() -> None: - """Test the short command encodes via the generic framing.""" - command = PanasonicAcHkCommand.short(kind="quiet") - assert command.get_raw_timings() == state_to_timings(QUIET_SHORT_FRAME)