[heatpumpir] Add ZMP IR receiver (#17099)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
BugHaver
2026-10-11 23:18:22 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent e92d239996
commit af76f19ab4
11 changed files with 250 additions and 9 deletions
@@ -1,5 +1,7 @@
#include "heatpumpir.h"
#include "receiver_mitsubishi_heavy.h"
#if defined(USE_ARDUINO) || defined(USE_ESP32)
#include <cmath>
@@ -246,6 +248,23 @@ void HeatpumpIRClimate::transmit_state() {
swing_h_cmd);
}
bool HeatpumpIRClimate::on_receive(remote_base::RemoteReceiveData data) {
switch (this->protocol_) {
case PROTOCOL_MITSUBISHI_HEAVY_ZMP: {
uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH];
if (!decode_mitsubishi_heavy_frame(data, frame, *this))
return false;
decode_mitsubishi_heavy_zmp(frame, *this);
break;
}
default:
return false; // no decoder for this protocol
}
this->target_temperature = clamp(this->target_temperature, this->min_temperature_, this->max_temperature_);
this->publish_state();
return true;
}
} // namespace esphome::heatpumpir
#endif
+10 -1
View File
@@ -102,7 +102,14 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR {
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL,
climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {}
void setup() override;
void set_protocol(Protocol protocol) { this->protocol_ = protocol; }
void set_protocol(Protocol protocol) {
this->protocol_ = protocol;
if (protocol == PROTOCOL_MITSUBISHI_HEAVY_ZMP) {
this->presets_.insert(climate::CLIMATE_PRESET_NONE);
this->presets_.insert(climate::CLIMATE_PRESET_ECO);
this->presets_.insert(climate::CLIMATE_PRESET_BOOST);
}
}
void set_horizontal_default(HorizontalDirection horizontal_direction) {
this->default_horizontal_direction_ = horizontal_direction;
}
@@ -117,6 +124,8 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR {
HeatpumpIR *heatpump_ir_;
/// Transmit via IR the state of this climate controller.
void transmit_state() override;
/// Handle received IR data from the remote control.
bool on_receive(remote_base::RemoteReceiveData data) override;
Protocol protocol_;
HorizontalDirection default_horizontal_direction_;
VerticalDirection default_vertical_direction_;
@@ -0,0 +1,109 @@
#include "receiver_mitsubishi_heavy.h"
#include "heatpumpir.h"
#if defined(USE_ARDUINO) || defined(USE_ESP32)
#include <HeatpumpIRFactory.h>
namespace esphome::heatpumpir {
static constexpr uint8_t FRAME_PREFIX[5] = {0x52, 0xAE, 0xC3, 0x26, 0xD9};
static constexpr uint8_t XOR_CHECKSUM_EXPECTED = 0xFF;
static constexpr uint8_t PREFIX_LENGTH = 5;
static constexpr uint8_t MODE_TEMP_BYTE = 9;
static constexpr uint8_t MODE_MASK = 0x07;
static constexpr uint8_t TEMP_MASK = 0x0F;
static constexpr uint8_t TEMPERATURE_BASE = 17;
static constexpr uint8_t TEMPERATURE_SHIFT = 4;
static bool read_raw_bytes(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) {
if (!data.expect_item(MITSUBISHI_HEAVY_HDR_MARK, MITSUBISHI_HEAVY_HDR_SPACE))
return false;
for (uint8_t i = 0; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i++) {
uint8_t byte = 0;
for (uint8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ONE_SPACE)) {
byte |= 1 << bit;
} else if (!data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ZERO_SPACE)) {
return false;
}
}
frame[i] = byte;
}
return true;
}
static bool validate_prefix(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) {
for (uint8_t i = 0; i < PREFIX_LENGTH; i++) {
if (frame[i] != FRAME_PREFIX[i])
return false;
}
return true;
}
static bool validate_checksums(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) {
for (uint8_t i = PREFIX_LENGTH; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i += 2) {
if ((uint8_t) (frame[i] ^ frame[i + 1]) != XOR_CHECKSUM_EXPECTED)
return false;
}
return true;
}
static bool decode_mode_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], climate::ClimateMode &mode) {
if (frame[MODE_TEMP_BYTE] & MITSUBISHI_HEAVY_MODE_OFF) {
mode = climate::CLIMATE_MODE_OFF;
return true;
}
switch (frame[MODE_TEMP_BYTE] & MODE_MASK) {
case MITSUBISHI_HEAVY_MODE_AUTO:
mode = climate::CLIMATE_MODE_HEAT_COOL;
return true;
case MITSUBISHI_HEAVY_MODE_HEAT:
mode = climate::CLIMATE_MODE_HEAT;
return true;
case MITSUBISHI_HEAVY_MODE_COOL:
mode = climate::CLIMATE_MODE_COOL;
return true;
case MITSUBISHI_HEAVY_MODE_DRY:
mode = climate::CLIMATE_MODE_DRY;
return true;
case MITSUBISHI_HEAVY_MODE_FAN:
mode = climate::CLIMATE_MODE_FAN_ONLY;
return true;
default:
return false;
}
}
static float decode_temperature_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) {
uint8_t temp_nibble = frame[MODE_TEMP_BYTE] >> TEMPERATURE_SHIFT;
uint8_t inverted_nibble = ~temp_nibble;
uint8_t temp_offset = inverted_nibble & TEMP_MASK;
return TEMPERATURE_BASE + temp_offset;
}
static bool apply_climate_state(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) {
climate::ClimateMode mode;
if (!decode_mode_field(frame, mode))
return false;
climate.mode = mode;
climate.target_temperature = decode_temperature_field(frame);
return true;
}
bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH],
HeatpumpIRClimate &climate) {
if (!read_raw_bytes(data, frame))
return false;
if (!validate_prefix(frame))
return false;
if (!validate_checksums(frame))
return false;
return apply_climate_state(frame, climate);
}
} // namespace esphome::heatpumpir
#endif
@@ -0,0 +1,20 @@
#pragma once
#include "esphome/components/remote_base/remote_base.h"
namespace esphome::heatpumpir {
class HeatpumpIRClimate;
/// Bytes in a Mitsubishi Heavy IR frame, prefix included
inline constexpr uint8_t MITSUBISHI_HEAVY_FRAME_LENGTH = 11;
/// Read a Mitsubishi Heavy frame into `frame`, check its prefix and inverted byte pairs, and apply the
/// fields every variant shares (mode and target temperature). Returns false when no valid frame was read.
bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH],
HeatpumpIRClimate &climate);
/// Apply the ZMP-specific fields (fan, preset, swing) of a validated frame.
void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate);
} // namespace esphome::heatpumpir
@@ -0,0 +1,63 @@
#include "receiver_mitsubishi_heavy.h"
#include "heatpumpir.h"
#if defined(USE_ARDUINO) || defined(USE_ESP32)
#include <HeatpumpIRFactory.h>
namespace esphome::heatpumpir {
// Fan speed sits in bits 7:5 of byte 7; the vertical swing code spans bit 1 of byte 5 and bits 4:3 of byte 7
static constexpr uint8_t BYTE_SWING_H = 5;
static constexpr uint8_t BYTE_FAN = 7;
static constexpr uint8_t FAN_SPEED_MASK = 0xE0;
static constexpr uint8_t VS_MASK_BYTE5 = 0x02;
static constexpr uint8_t VS_MASK_BYTE7 = 0x18;
// The library has no horizontal swing for ZMP and always sends MITSUBISHI_HEAVY_ZMP_HS_STOP (0xCC) in byte 5.
// Captures of the remote show 0x5C while it swings; the mask leaves out the vertical swing and clean bits.
static constexpr uint8_t HORIZONTAL_SWING_MASK = 0xDC;
static constexpr uint8_t HORIZONTAL_SWING_ACTIVE = 0x5C;
void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) {
uint8_t fan = frame[BYTE_FAN] & FAN_SPEED_MASK;
climate.preset = climate::CLIMATE_PRESET_NONE;
switch (fan) {
case MITSUBISHI_HEAVY_ZMP_FAN_AUTO:
climate.fan_mode = climate::CLIMATE_FAN_AUTO;
break;
case MITSUBISHI_HEAVY_ZMP_FAN1:
climate.fan_mode = climate::CLIMATE_FAN_LOW;
break;
case MITSUBISHI_HEAVY_ZMP_FAN2:
climate.fan_mode = climate::CLIMATE_FAN_MEDIUM;
break;
case MITSUBISHI_HEAVY_ZMP_FAN3:
climate.fan_mode = climate::CLIMATE_FAN_HIGH;
break;
case MITSUBISHI_HEAVY_ZMP_HIPOWER:
climate.preset = climate::CLIMATE_PRESET_BOOST;
break; // the remote keeps the fan mode and restores it when the preset ends
case MITSUBISHI_HEAVY_ZMP_ECONO:
climate.preset = climate::CLIMATE_PRESET_ECO;
break;
}
uint8_t swing_h = frame[BYTE_SWING_H] & HORIZONTAL_SWING_MASK;
uint8_t swing_v = (frame[BYTE_SWING_H] & VS_MASK_BYTE5) | (frame[BYTE_FAN] & VS_MASK_BYTE7);
bool h_swing = (swing_h == HORIZONTAL_SWING_ACTIVE);
bool v_swing = (swing_v == MITSUBISHI_HEAVY_ZMP_VS_SWING);
if (h_swing && v_swing) {
climate.swing_mode = climate::CLIMATE_SWING_BOTH;
} else if (h_swing) {
climate.swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
} else if (v_swing) {
climate.swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else {
climate.swing_mode = climate::CLIMATE_SWING_OFF;
}
}
} // namespace esphome::heatpumpir
#endif
+9
View File
@@ -7,6 +7,15 @@ climate:
min_temperature: 18
max_temperature: 30
transmitter_id: xmitr
- platform: heatpumpir
protocol: mitsubishi_heavy_zmp
horizontal_default: left
vertical_default: up
name: HeatpumpIR Climate Mitsubishi ZMP
min_temperature: 18
max_temperature: 30
transmitter_id: xmitr
receiver_id: rcvr
- platform: heatpumpir
protocol: daikin
horizontal_default: mleft
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/bk72xx-ard.yaml
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/bk72xx-ard.yaml
heatpumpir: !include common.yaml
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml
heatpumpir: !include common.yaml
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
heatpumpir: !include common.yaml
@@ -1,4 +1,4 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
<<: !include common.yaml
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
heatpumpir: !include common.yaml
@@ -0,0 +1,12 @@
# Common remote_receiver configuration for BK72XX Arduino tests
# Provides a shared remote receiver that all components can use
# Components will auto-use this receiver if they don't specify receiver_id
substitutions:
remote_receiver_pin: GPIO17
remote_receiver:
id: rcvr
pin: ${remote_receiver_pin}
dump: all
tolerance: 25%