Add water heater component for Panasonic heat pump integration

This commit is contained in:
ElVit 2026-02-06 13:46:12 +01:00
parent b2b041f9db
commit 3376fdfced
15 changed files with 349 additions and 42 deletions

View File

@ -13,12 +13,10 @@ from .. import (
)
CONF_COOL_MODE = "cool_mode"
CONF_CLIMATE_TANK = "tank"
CONF_CLIMATE_ZONE1 = "zone1"
CONF_CLIMATE_ZONE2 = "zone2"
TYPES = [
CONF_CLIMATE_TANK,
CONF_CLIMATE_ZONE1,
CONF_CLIMATE_ZONE2,
]
@ -45,9 +43,6 @@ CONFIG_SCHEMA = cv.Schema(
PanasonicHeatpumpComponent
),
cv.Optional(CONF_COOL_MODE, default=False): cv.boolean,
cv.Optional(CONF_CLIMATE_TANK): climate.climate_schema(
PanasonicHeatpumpClimate
).extend(climate_options(20.0, 65.0, 0.5)),
cv.Optional(CONF_CLIMATE_ZONE1): climate.climate_schema(
PanasonicHeatpumpClimate
).extend(climate_options(-5.0, 5.0, 0.5)),

View File

@ -42,9 +42,6 @@ void PanasonicHeatpumpClimate::control(const climate::ClimateCall& call) {
if (call.get_target_temperature().has_value()) {
float new_temp = *call.get_target_temperature();
switch (this->id_) {
case ClimateIds::CONF_CLIMATE_TANK:
this->parent_->set_command_byte(PanasonicCommand::setPlus128(new_temp), 42); // set11
break;
case ClimateIds::CONF_CLIMATE_ZONE1:
this->parent_->set_command_byte(PanasonicCommand::setPlus128(new_temp), 38); // set5
break;
@ -97,10 +94,6 @@ void PanasonicHeatpumpClimate::publish_new_state(const std::vector<uint8_t>& dat
new_mode = this->getClimateMode(data[6]); // set9
switch (this->id_) {
case ClimateIds::CONF_CLIMATE_TANK:
new_target_temp_heat = PanasonicDecode::getByteMinus128(data[42]); // set11
new_current_temp = PanasonicDecode::getByteMinus128(data[141]); // top10
break;
case ClimateIds::CONF_CLIMATE_ZONE1:
new_target_temp_heat = PanasonicDecode::getByteMinus128(data[38]); // set5
new_target_temp_cool = PanasonicDecode::getByteMinus128(data[39]); // set6
@ -124,9 +117,8 @@ void PanasonicHeatpumpClimate::publish_new_state(const std::vector<uint8_t>& dat
this->target_temperature_low == new_target_temp_cool && this->current_temperature == new_current_temp)
return;
if (new_mode != 255)
if (new_mode != 0xFF)
this->mode = (climate::ClimateMode)new_mode;
// this->action = climate::CLIMATE_ACTION_IDLE;
if (this->get_traits().has_feature_flags(climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
this->target_temperature_high = new_target_temp_heat;
this->target_temperature_low = new_target_temp_cool;
@ -139,15 +131,6 @@ void PanasonicHeatpumpClimate::publish_new_state(const std::vector<uint8_t>& dat
uint8_t PanasonicHeatpumpClimate::getClimateMode(const uint8_t input) {
switch (this->id_) {
case ClimateIds::CONF_CLIMATE_TANK:
switch ((uint8_t)(input & 0b110000)) {
case 0b010000:
return climate::CLIMATE_MODE_OFF;
case 0b100000:
return climate::CLIMATE_MODE_HEAT;
default:
return 255;
};
case ClimateIds::CONF_CLIMATE_ZONE1:
case ClimateIds::CONF_CLIMATE_ZONE2:
switch ((uint8_t)(input & 0b1111)) {
@ -164,26 +147,16 @@ uint8_t PanasonicHeatpumpClimate::getClimateMode(const uint8_t input) {
case 0b1010:
return climate::CLIMATE_MODE_AUTO; // 0x1A = auto-cool
default:
return 255;
return 0xFF;
};
default:
return 255;
return 0xFF;
};
}
uint8_t PanasonicHeatpumpClimate::setClimateMode(const climate::ClimateMode mode, uint8_t byte) {
uint8_t newByte = byte;
switch (this->id_) {
case ClimateIds::CONF_CLIMATE_TANK:
newByte = newByte & 0b11001111;
switch (mode) {
case climate::CLIMATE_MODE_OFF:
return newByte + 0b010000;
case climate::CLIMATE_MODE_HEAT:
return newByte + 0b100000;
default:
return 0;
};
case ClimateIds::CONF_CLIMATE_ZONE1:
case ClimateIds::CONF_CLIMATE_ZONE2:
newByte = newByte & 0b11110000;

View File

@ -9,7 +9,6 @@
namespace esphome {
namespace panasonic_heatpump {
enum ClimateIds : uint8_t {
CONF_CLIMATE_TANK,
CONF_CLIMATE_ZONE1,
CONF_CLIMATE_ZONE2,
};

View File

@ -99,7 +99,7 @@ uint8_t PanasonicCommand::setOperationMode(size_t input) {
}
}
// start of optional pcb commands
// --- start of optional pcb commands ---
uint8_t PanasonicCommand::temp2hex(float temp) {
int hextemp = 0;

View File

@ -93,7 +93,13 @@ void PanasonicHeatpumpComponent::loop() {
for (auto* entity : this->climates_) {
entity->publish_new_state(this->heatpump_default_message_);
}
this->loop_state_ = LoopState::SEND_REQUEST;
this->loop_state_ = LoopState::PUBLISH_WATER_HEATER;
break;
case LoopState::PUBLISH_WATER_HEATER:
for (auto* entity : this->water_heaters_) {
entity->publish_new_state(this->heatpump_default_message_);
}
this->loop_state_ = LoopState::PUBLISH_EXTRA_SENSOR;
break;
case LoopState::PUBLISH_EXTRA_SENSOR:
for (auto* entity : this->extra_sensors_) {

View File

@ -27,6 +27,7 @@ enum LoopState : uint8_t {
PUBLISH_SELECT,
PUBLISH_SWITCH,
PUBLISH_CLIMATE,
PUBLISH_WATER_HEATER,
PUBLISH_EXTRA_SENSOR,
SEND_REQUEST,
READ_REQUEST,
@ -95,6 +96,9 @@ class PanasonicHeatpumpComponent : public PollingComponent, public uart::UARTDev
void add_climate(PanasonicHeatpumpEntity* climate) {
climates_.push_back(climate);
}
void add_water_heater(PanasonicHeatpumpEntity* water_heater) {
water_heaters_.push_back(water_heater);
}
void add_number(PanasonicHeatpumpEntity* number) {
numbers_.push_back(number);
}
@ -143,6 +147,7 @@ class PanasonicHeatpumpComponent : public PollingComponent, public uart::UARTDev
// entity vectors
std::vector<PanasonicHeatpumpEntity*> binary_sensors_;
std::vector<PanasonicHeatpumpEntity*> climates_;
std::vector<PanasonicHeatpumpEntity*> water_heaters_;
std::vector<PanasonicHeatpumpEntity*> numbers_;
std::vector<PanasonicHeatpumpEntity*> selects_;
std::vector<PanasonicHeatpumpEntity*> sensors_;

View File

@ -0,0 +1,60 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import water_heater
from esphome.const import (
CONF_MIN_TEMPERATURE,
CONF_MAX_TEMPERATURE,
)
from .. import (
CONF_PANASONIC_HEATPUMP_ID,
PanasonicHeatpumpComponent,
panasonic_heatpump_ns,
)
CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step"
CONF_TANK = "tank"
TYPES = [
CONF_TANK,
]
def water_heater_options(min_temp, max_temp, temp_step) -> cv.Schema:
schema = cv.Schema(
{
cv.Optional(CONF_MIN_TEMPERATURE, default=min_temp): cv.float_,
cv.Optional(CONF_MAX_TEMPERATURE, default=max_temp): cv.float_,
cv.Optional(CONF_TARGET_TEMPERATURE_STEP, default=temp_step): cv.float_,
}
)
return schema
PanasonicHeatpumpWaterHeater = panasonic_heatpump_ns.class_(
"PanasonicHeatpumpWaterHeater", water_heater.WaterHeater, cg.Component
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_PANASONIC_HEATPUMP_ID): cv.use_id(
PanasonicHeatpumpComponent
),
cv.Optional(CONF_TANK): water_heater.water_heater_schema(
PanasonicHeatpumpWaterHeater
).extend(water_heater_options(20.0, 65.0, 0.5)),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
parent = await cg.get_variable(config[CONF_PANASONIC_HEATPUMP_ID])
for index, key in enumerate(TYPES):
if child_config := config.get(key):
var = await water_heater.new_water_heater(child_config)
cg.add(var.set_parent(parent))
cg.add(var.set_id(index))
cg.add(var.set_min_temperature(child_config[CONF_MIN_TEMPERATURE]))
cg.add(var.set_max_temperature(child_config[CONF_MAX_TEMPERATURE]))
cg.add(var.set_temperature_step(child_config[CONF_TARGET_TEMPERATURE_STEP]))
cg.add(parent.add_water_heater(var))

View File

@ -0,0 +1,116 @@
#include "panasonic_heatpump_water_heater.h"
#include "esphome/core/log.h"
namespace esphome {
namespace panasonic_heatpump {
static const char* const TAG = "panasonic_heatpump.water_heater";
void PanasonicHeatpumpWaterHeater::dump_config() {
LOG_WATER_HEATER("", "Panasonic Heatpump Water Heater", this);
}
water_heater::WaterHeaterTraits PanasonicHeatpumpWaterHeater::traits() {
auto traits = water_heater::WaterHeaterTraits();
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_supported_modes({water_heater::WATER_HEATER_MODE_OFF, water_heater::WATER_HEATER_MODE_HEAT_PUMP});
traits.set_min_temperature(this->min_temperature_);
traits.set_max_temperature(this->max_temperature_);
traits.set_target_temperature_step(this->temperature_step_);
return traits;
}
void PanasonicHeatpumpWaterHeater::control(const water_heater::WaterHeaterCall& call) {
if (call.get_mode().has_value()) {
int byte6 = this->parent_->getResponseByte(6);
if (byte6 >= 0) {
water_heater::WaterHeaterMode new_mode = *call.get_mode();
uint8_t newByte6 = this->setWaterHeaterMode(new_mode, (uint8_t)byte6);
this->parent_->set_command_byte(newByte6, 6);
}
}
float new_temp = call.get_target_temperature();
switch (this->id_) {
case WaterHeaterIds::CONF_TANK:
this->parent_->set_command_byte(PanasonicCommand::setPlus128(new_temp), 42); // set11
break;
};
this->publish_state();
this->keep_state_ = 2;
}
void PanasonicHeatpumpWaterHeater::publish_new_state(const std::vector<uint8_t>& data) {
if (this->keep_state_ > 0) {
this->keep_state_--;
return;
}
if (data.empty())
return;
uint8_t new_mode;
float new_target_temp_heat;
float new_current_temp;
new_mode = this->getWaterHeaterMode(data[6]); // set9
switch (this->id_) {
case WaterHeaterIds::CONF_TANK:
new_target_temp_heat = PanasonicDecode::getByteMinus128(data[42]); // set11
new_current_temp = PanasonicDecode::getByteMinus128(data[141]); // top10
break;
default:
return;
};
if (this->mode_ == new_mode && this->target_temperature_ == new_target_temp_heat &&
this->current_temperature_ == new_current_temp)
return;
if (new_mode != 0xFF)
this->mode_ = (water_heater::WaterHeaterMode)new_mode;
this->target_temperature_ = new_target_temp_heat;
this->current_temperature_ = new_current_temp;
this->publish_state();
}
uint8_t PanasonicHeatpumpWaterHeater::getWaterHeaterMode(const uint8_t input) {
switch (this->id_) {
case WaterHeaterIds::CONF_TANK:
switch ((uint8_t)(input & 0b110000)) {
case 0b010000:
return water_heater::WATER_HEATER_MODE_OFF;
case 0b100000:
return water_heater::WATER_HEATER_MODE_HEAT_PUMP;
default:
return 0xFF;
};
default:
return 0xFF;
};
}
uint8_t PanasonicHeatpumpWaterHeater::setWaterHeaterMode(const water_heater::WaterHeaterMode mode, const uint8_t byte) {
uint8_t newByte = byte;
switch (this->id_) {
case WaterHeaterIds::CONF_TANK:
newByte = newByte & 0b11001111;
switch (mode) {
case water_heater::WATER_HEATER_MODE_OFF:
return newByte + 0b010000;
case water_heater::WATER_HEATER_MODE_HEAT_PUMP:
return newByte + 0b100000;
default:
return 0;
};
default:
return 0;
};
}
water_heater::WaterHeaterCallInternal PanasonicHeatpumpWaterHeater::make_call() {
return water_heater::WaterHeaterCallInternal(this);
}
} // namespace panasonic_heatpump
} // namespace esphome

View File

@ -0,0 +1,45 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/water_heater/water_heater.h"
#include "../panasonic_heatpump.h"
#include "../decode.h"
#include "../commands.h"
#include <set>
namespace esphome {
namespace panasonic_heatpump {
enum WaterHeaterIds : uint8_t {
CONF_TANK,
};
class PanasonicHeatpumpWaterHeater : public water_heater::WaterHeater,
public Parented<PanasonicHeatpumpComponent>,
public PanasonicHeatpumpEntity {
public:
PanasonicHeatpumpWaterHeater() = default;
void dump_config() override;
water_heater::WaterHeaterTraits traits() override;
void publish_new_state(const std::vector<uint8_t>& data) override;
void set_min_temperature(float value) {
this->min_temperature_ = value;
}
void set_max_temperature(float value) {
this->max_temperature_ = value;
}
void set_temperature_step(float value) {
this->temperature_step_ = value;
}
protected:
void control(const water_heater::WaterHeaterCall& call) override;
water_heater::WaterHeaterCallInternal make_call() override;
uint8_t getWaterHeaterMode(const uint8_t input);
uint8_t setWaterHeaterMode(const water_heater::WaterHeaterMode mode, const uint8_t byte);
float min_temperature_{20.0f};
float max_temperature_{65.0f};
float temperature_step_{0.5f};
};
} // namespace panasonic_heatpump
} // namespace esphome

View File

@ -82,6 +82,12 @@ switch:
# Test climate controls - first item only
climate:
- platform: panasonic_heatpump
zone1:
name: "Zone 1"
# Test water_heater controls - first item only
water_heater:
- platform: panasonic_heatpump
tank:
name: "DHW"

View File

@ -73,6 +73,12 @@ switch:
# Test climate controls - first item only
climate:
- platform: panasonic_heatpump
zone1:
name: "Zone 1"
# Test water_heater controls - first item only
water_heater:
- platform: panasonic_heatpump
tank:
name: "DHW"

View File

@ -73,6 +73,12 @@ switch:
# Test climate controls - first item only
climate:
- platform: panasonic_heatpump
zone1:
name: "Zone 1"
# Test water_heater controls - first item only
water_heater:
- platform: panasonic_heatpump
tank:
name: "DHW"

View File

@ -70,6 +70,12 @@ switch:
# Test climate controls - first item only
climate:
- platform: panasonic_heatpump
zone1:
name: "Zone 1"
# Test water_heater controls - first item only
water_heater:
- platform: panasonic_heatpump
tank:
name: "DHW"

View File

@ -472,9 +472,13 @@ switch:
climate:
- platform: panasonic_heatpump
cool_mode: true
zone1:
name: "Zone 1"
zone2:
name: "Zone 2"
# Test water_heater platform
water_heater:
- platform: panasonic_heatpump
tank:
name: "DHW"
zone1:
name: "Zone1"
zone2:
name: "Zone2"

View File

@ -325,6 +325,86 @@ class TestPanasonicHeatpumpPlatforms:
except ImportError:
pytest.skip("Climate platform not accessible in test environment")
def test_water_heater_platform_exists(self):
"""Test that water_heater platform can be imported."""
try:
import sys
import os
components_path = os.path.join(
os.path.dirname(__file__), "..", "..", "components"
)
sys.path.insert(0, components_path)
from panasonic_heatpump.water_heater import __init__ as wh_init
assert wh_init is not None
except ImportError:
pytest.skip("Water heater platform not accessible in test environment")
def test_water_heater_platform_metadata(self):
"""Test that water_heater platform has correct configuration."""
try:
import sys
import os
components_path = os.path.join(
os.path.dirname(__file__), "..", "..", "components"
)
sys.path.insert(0, components_path)
from panasonic_heatpump.water_heater import (
CONFIG_SCHEMA,
CONF_TANK,
TYPES,
)
assert CONFIG_SCHEMA is not None
assert CONF_TANK == "tank"
assert CONF_TANK in TYPES
except ImportError:
pytest.skip("Water heater platform not accessible in test environment")
def test_water_heater_platform_options(self):
"""Test that water_heater_options schema is defined."""
try:
import sys
import os
components_path = os.path.join(
os.path.dirname(__file__), "..", "..", "components"
)
sys.path.insert(0, components_path)
from panasonic_heatpump.water_heater import water_heater_options
# Test default temperature range and step
schema = water_heater_options(20.0, 65.0, 0.5)
assert schema is not None
# Verify defaults work
schema2 = water_heater_options(15.0, 70.0, 1.0)
assert schema2 is not None
except ImportError:
pytest.skip("Water heater platform not accessible in test environment")
def test_water_heater_to_code_function(self):
"""Test that water_heater to_code function is defined."""
try:
import sys
import os
components_path = os.path.join(
os.path.dirname(__file__), "..", "..", "components"
)
sys.path.insert(0, components_path)
from panasonic_heatpump.water_heater import to_code
assert callable(to_code)
except ImportError:
pytest.skip("Water heater platform not accessible in test environment")
class TestPanasonicHeatpumpCodeGeneration:
"""Test suite for code generation logic."""