[mitsubishi_cn105] Add Fahrenheit support (#15488)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Boris Krivonog
2026-08-21 23:52:18 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent a30e82459f
commit 65704e881f
9 changed files with 121 additions and 10 deletions
@@ -8,6 +8,7 @@ from esphome.const import (
CONF_ON_STATE, CONF_ON_STATE,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_UPDATE_INTERVAL, CONF_UPDATE_INTERVAL,
CONF_USE_FAHRENHEIT,
) )
from esphome.core import ID, Lambda from esphome.core import ID, Lambda
from esphome.cpp_generator import LambdaExpression, MockObj from esphome.cpp_generator import LambdaExpression, MockObj
@@ -71,6 +72,7 @@ CONFIG_SCHEMA = (
cv.Optional( cv.Optional(
CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s"
): cv.update_interval, ): cv.update_interval,
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
cv.Optional(CONF_VANE): cv.Schema( cv.Optional(CONF_VANE): cv.Schema(
{ {
cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
@@ -114,6 +116,7 @@ async def to_code(config: ConfigType) -> None:
config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL]
) )
) )
cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT]))
if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE):
cg.add_global(mitsubishi_ns.using) cg.add_global(mitsubishi_ns.using)
for conf in on_state: for conf in on_state:
@@ -83,6 +83,7 @@ class MitsubishiCN105 {
return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature)
: !std::isnan(this->status_.target_temperature); : !std::isnan(this->status_.target_temperature);
} }
bool is_temperature_encoding_b() const { return this->property_context_.use_temperature_encoding_b; }
void set_power(bool power_on); void set_power(bool power_on);
void set_target_temperature(float target_temperature); void set_target_temperature(float target_temperature);
@@ -50,7 +50,11 @@ static constexpr std::optional<Left> reverse_map_lookup(const std::array<std::pa
return key.has_value() ? reverse_map_lookup(map, *key) : std::nullopt; return key.has_value() ? reverse_map_lookup(map, *key) : std::nullopt;
} }
void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); } void MitsubishiCN105Climate::dump_config() {
LOG_CLIMATE("", "Mitsubishi CN105 Climate", this);
ESP_LOGCONFIG(TAG, " Temperature unit: °%c",
this->parent_->get_temperature_mapping().get_use_fahrenheit() ? 'F' : 'C');
}
void MitsubishiCN105Climate::setup() { void MitsubishiCN105Climate::setup() {
this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); this->parent_->add_on_status_callback([this]() { this->apply_values_(); });
@@ -72,13 +76,15 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
traits.set_supported_swing_modes(this->supported_swing_modes_); traits.set_supported_swing_modes(this->supported_swing_modes_);
traits.set_visual_min_temperature(16.0f); const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit();
traits.set_visual_max_temperature(31.0f); traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS);
traits.set_visual_min_temperature(use_fahrenheit ? 61.0f : 16.0f);
traits.set_visual_max_temperature(use_fahrenheit ? 88.0f : 31.0f);
traits.set_visual_temperature_step(1.0f); traits.set_visual_temperature_step(1.0f);
if (this->parent_->is_telemetry_polling_enabled()) { if (this->parent_->is_telemetry_polling_enabled()) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_visual_current_temperature_step(0.5f); traits.set_visual_current_temperature_step(use_fahrenheit ? 1.0f : 0.5f);
} }
return traits; return traits;
@@ -86,7 +92,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
if (const auto target_temperature = call.get_target_temperature()) { if (const auto target_temperature = call.get_target_temperature()) {
this->parent_->set_target_temperature(*target_temperature); this->parent_->set_target_temperature(this->parent_->get_temperature_mapping().to_mitsubishi(*target_temperature));
} }
if (const auto mode = call.get_mode()) { if (const auto mode = call.get_mode()) {
@@ -139,10 +145,10 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
void MitsubishiCN105Climate::apply_values_() { void MitsubishiCN105Climate::apply_values_() {
const auto &status = this->parent_->status(); const auto &status = this->parent_->status();
this->target_temperature = status.target_temperature; this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature);
if (this->parent_->is_telemetry_polling_enabled()) { if (this->parent_->is_telemetry_polling_enabled()) {
this->current_temperature = status.room_temperature; this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature);
} }
if (status.power_on) { if (status.power_on) {
@@ -27,6 +27,13 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); }
void MitsubishiCN105Component::loop() { void MitsubishiCN105Component::loop() {
if (this->hp_.update()) { if (this->hp_.update()) {
// Encoding A only supports whole °C values and cannot represent native °F setpoints accurately.
// See https://github.com/esphome/esphome/pull/15488#issuecomment-5268304343
if (this->temperature_mapping_.get_use_fahrenheit() && !this->hp_.is_temperature_encoding_b()) {
ESP_LOGE(TAG, "Unit reports encoding A, which cannot accurately convert °F setpoints; disable 'use_fahrenheit'");
this->mark_failed();
return;
}
this->notify_status_listeners_(); this->notify_status_listeners_();
} }
} }
@@ -3,13 +3,43 @@
#include "mitsubishi_cn105.h" #include "mitsubishi_cn105.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/uart/uart.h" #include "esphome/components/uart/uart.h"
#include <utility> #include <algorithm>
#include <cmath>
#include <optional> #include <optional>
#include <utility>
namespace esphome::mitsubishi_cn105 { namespace esphome::mitsubishi_cn105 {
struct TemperatureMapping {
float to_mitsubishi(float value) const {
if (!this->use_fahrenheit_) {
return value;
}
const int fahrenheit = std::clamp(static_cast<int>(std::round(value)), 61, 88);
return 0.5f * (fahrenheit - 28 + (fahrenheit > 68) - (fahrenheit < 68));
}
float from_mitsubishi(float value) const {
if (!this->use_fahrenheit_) {
return value;
}
if (value < 16.0f || value > 30.5f) {
return celsius_to_fahrenheit(value);
}
const int mitsubishi_half_degrees = static_cast<int>(std::round(value * 2.0f));
return mitsubishi_half_degrees + 29 - (mitsubishi_half_degrees >= 40) - (mitsubishi_half_degrees > 40);
}
bool get_use_fahrenheit() const { return this->use_fahrenheit_; }
void set_use_fahrenheit(bool value) { this->use_fahrenheit_ = value; }
protected:
bool use_fahrenheit_{false};
};
enum VerticalVaneMode : uint8_t { enum VerticalVaneMode : uint8_t {
VERTICAL_VANE_MODE_AUTO = static_cast<uint8_t>(MitsubishiCN105::VaneMode::AUTO), VERTICAL_VANE_MODE_AUTO = static_cast<uint8_t>(MitsubishiCN105::VaneMode::AUTO),
VERTICAL_VANE_MODE_POSITION_1 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_1), VERTICAL_VANE_MODE_POSITION_1 = static_cast<uint8_t>(MitsubishiCN105::VaneMode::POSITION_1),
@@ -60,6 +90,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice {
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); }
void set_use_fahrenheit(bool value) { this->temperature_mapping_.set_use_fahrenheit(value); }
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
@@ -75,6 +106,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice {
const MitsubishiCN105::Status &status() const { return this->hp_.status(); } const MitsubishiCN105::Status &status() const { return this->hp_.status(); }
bool is_status_initialized() const { return this->hp_.is_status_initialized(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); }
bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); }
const TemperatureMapping &get_temperature_mapping() const { return this->temperature_mapping_; }
template<typename F> void add_on_status_callback(F &&callback) { template<typename F> void add_on_status_callback(F &&callback) {
this->status_callback_.add(std::forward<F>(callback)); this->status_callback_.add(std::forward<F>(callback));
@@ -99,6 +131,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice {
} }
MitsubishiCN105 hp_; MitsubishiCN105 hp_;
TemperatureMapping temperature_mapping_;
CallbackManager<void()> status_callback_; CallbackManager<void()> status_callback_;
LazyCallbackManager<void(const VaneState &)> vane_state_callback_; LazyCallbackManager<void(const VaneState &)> vane_state_callback_;
}; };
+1 -2
View File
@@ -118,8 +118,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f;
// current_temp_step // current_temp_step
root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f;
// temperature units are always coerced to Celsius internally root[MQTT_TEMPERATURE_UNIT] = traits.get_temperature_unit() == TemperatureUnit::FAHRENHEIT ? "F" : "C";
root[MQTT_TEMPERATURE_UNIT] = "C";
// min_humidity // min_humidity
root[MQTT_MIN_HUMIDITY] = traits.get_visual_min_humidity(); root[MQTT_MIN_HUMIDITY] = traits.get_visual_min_humidity();
@@ -1,7 +1,67 @@
#include <array>
#include <utility>
#include "../common.h" #include "../common.h"
namespace esphome::mitsubishi_cn105::testing { namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
const auto mapping = TemperatureMapping();
for (int temperature = 16; temperature <= 31; ++temperature) {
EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature);
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
}
const auto traits = sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
sut.set_use_fahrenheit(true);
const std::array cases{
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f},
std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f},
std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f},
std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f},
std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f},
};
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) {
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
const std::array cases{
std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f},
std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f},
};
for (const auto &[celsius, fahrenheit] : cases) {
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit);
}
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) { TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut; TestableMitsubishiCN105Climate sut;
@@ -73,6 +73,7 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_; using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); } MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); }
protected: protected:
MitsubishiCN105Component component_; MitsubishiCN105Component component_;
@@ -3,6 +3,7 @@ mitsubishi_cn105:
uart_id: uart_bus uart_id: uart_bus
update_interval: 30s update_interval: 30s
telemetry_request_min_interval: 120s telemetry_request_min_interval: 120s
use_fahrenheit: true
vane: vane:
on_state: on_state:
- logger.log: - logger.log: