[remko_ar715] Add Remko AR-715 IR climate platform (#17173)

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@home-assistant.io>
This commit is contained in:
naKruul
2026-10-12 13:09:40 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston
parent 81c275777d
commit 73d5bb3c97
10 changed files with 277 additions and 0 deletions
+1
View File
@@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet
esphome/components/rc522_i2c/* @glmnet
esphome/components/rc522_spi/* @glmnet
esphome/components/rd03d/* @jasstrong
esphome/components/remko_ar715/* @naKruul
esphome/components/renogy_inverter_ble/* @emilioaray-dev
esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
@@ -0,0 +1,2 @@
CODEOWNERS = ["@naKruul"]
DOMAIN = "remko_ar715"
+22
View File
@@ -0,0 +1,22 @@
import esphome.codegen as cg
from esphome.components import climate_ir
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
import esphome.config_validation as cv
from esphome.const import CONF_SUPPORTS_HEAT
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
remko_ar715_ns = cg.esphome_ns.namespace("remko_ar715")
RemkoAr715Climate = remko_ar715_ns.class_("RemkoAr715Climate", climate_ir.ClimateIR)
CONFIG_SCHEMA = climate_ir.climate_ir_schema(RemkoAr715Climate).extend(
{
cv.Optional(CONF_SUPPORTS_HEAT, default=False): cv.boolean_false,
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
}
)
async def to_code(config: ConfigType) -> None:
await climate_ir.new_climate_ir(config)
@@ -0,0 +1,93 @@
#include "remko_ar715.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::remko_ar715 {
ESPHOME_LOG_TAG(TAG, "remko_ar715.climate");
// Timings from ir_ClimaButler.cpp (IRremoteESP8266), in microseconds
static constexpr uint16_t BIT_MARK = 511;
static constexpr uint16_t HDR_SPACE = 3492;
static constexpr uint16_t ONE_SPACE = 1540;
static constexpr uint16_t ZERO_SPACE = 548;
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
static constexpr uint8_t BITS = 52;
static constexpr uint8_t POWER_ON = 0x3;
static constexpr uint8_t POWER_OFF = 0x0;
static constexpr uint8_t SWING_ON = 0x5;
static constexpr uint8_t SWING_OFF = 0x7;
// Mode/fan byte: high nibble N3, low nibble N2
static constexpr uint8_t MODE_FAN_AUTO = 0x80;
static constexpr uint8_t MODE_FAN_LOW = 0xA2;
static constexpr uint8_t MODE_FAN_MEDIUM = 0x91;
static constexpr uint8_t MODE_FAN_HIGH = 0x88;
static constexpr uint8_t MODE_DRY = 0xA4;
static constexpr uint8_t MODE_FAN_ONLY = 0xA3;
static uint8_t fan_mode_byte(climate::ClimateFanMode fan_mode) {
switch (fan_mode) {
case climate::CLIMATE_FAN_HIGH:
return MODE_FAN_HIGH;
case climate::CLIMATE_FAN_MEDIUM:
return MODE_FAN_MEDIUM;
case climate::CLIMATE_FAN_LOW:
return MODE_FAN_LOW;
default:
return MODE_FAN_AUTO;
}
}
// 52-bit word, 13 nibbles MSB first:
// N12=0x8 (fixed), N11=power, N10..N5=0 (timer, unused), N4=swing,
// N3,N2=mode/fan, N1=temp-16, N0=checksum (0xF - sum(N12..N1)) & 0xF
static uint64_t build_frame(uint8_t power, uint8_t swing, uint8_t mode_fan, uint8_t temp) {
const uint8_t temp_nibble = (temp - 16) & 0xF;
const uint8_t sum = 0x8 + power + swing + (mode_fan >> 4) + (mode_fan & 0xF) + temp_nibble;
const uint8_t checksum = (0xF - sum) & 0xF;
return (uint64_t{0x8} << 48) | (uint64_t{power} << 44) | (uint32_t{swing} << 16) | (uint32_t{mode_fan} << 8) |
(temp_nibble << 4) | checksum;
}
static void send_frame(remote_base::RemoteTransmitData *data, uint64_t code) {
data->set_carrier_frequency(CARRIER_FREQUENCY);
data->reserve(2 + BITS * 2 + 2);
data->item(BIT_MARK, HDR_SPACE);
for (int i = BITS - 1; i >= 0; i--)
data->item(BIT_MARK, (code >> i) & 1 ? ONE_SPACE : ZERO_SPACE);
// Stop bit and inter-frame gap
data->item(BIT_MARK, HDR_SPACE);
}
void RemkoAr715Climate::transmit_state() {
const uint8_t temp = lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX));
const bool power = this->mode != climate::CLIMATE_MODE_OFF;
const bool swing = this->swing_mode == climate::CLIMATE_SWING_VERTICAL;
// DRY and FAN_ONLY carry no fan speed: mode and fan share the same nibbles
uint8_t mode_fan;
switch (this->mode) {
case climate::CLIMATE_MODE_DRY:
mode_fan = MODE_DRY;
break;
case climate::CLIMATE_MODE_FAN_ONLY:
mode_fan = MODE_FAN_ONLY;
break;
default:
mode_fan = fan_mode_byte(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO));
break;
}
const uint64_t code = build_frame(power ? POWER_ON : POWER_OFF, swing ? SWING_ON : SWING_OFF, mode_fan, temp);
ESP_LOGV(TAG, "Sending power=%s temp=%u swing=%s mode_fan=0x%02X code=0x%05" PRIX32 "%08" PRIX32, ONOFF(power), temp,
ONOFF(swing), mode_fan, static_cast<uint32_t>(code >> 32), static_cast<uint32_t>(code));
auto transmit = this->transmitter_->transmit();
send_frame(transmit.get_data(), code);
transmit.perform();
}
} // namespace esphome::remko_ar715
@@ -0,0 +1,37 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
namespace esphome::remko_ar715 {
// Remko AR-715 IR remote: 52-bit pulse-distance frame, full state in every command.
// IRremoteESP8266 calls it the ClimaButler protocol after a rebrand; Remko is the manufacturer.
// Protocol analysis: https://github.com/crankyoldgit/IRremoteESP8266/issues/1812
//
// Supported devices:
// - Remko RKL series (RKL 490, 491, 494, 495) with AR-715 remote
// - Fischer ClimaButler RCS-SD43UAI / RCS-SD43UWI
// - TROTEC PAC 4600
// - Novamatic CL 990 / CL 1590
// - Rexair C15000N
// - freecom RCS-SD43UAI / RCS-SD43UWI
static constexpr float TEMP_MIN = 16.0f;
static constexpr float TEMP_MAX = 30.0f;
class RemkoAr715Climate final : public climate_ir::ClimateIR {
public:
RemkoAr715Climate()
: climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX,
1.0f, // temperature step
true, // supports dry
true, // supports fan only
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {}
protected:
void transmit_state() override;
};
} // namespace esphome::remko_ar715
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The harness walks dependencies from the component manifest, not from the
# climate platform, so declare the platform's auto-load here.
manifest.auto_load = ["climate_ir", "climate"]
+3
View File
@@ -0,0 +1,3 @@
climate:
- platform: remko_ar715
name: Test RemkoAr715
@@ -0,0 +1,106 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/components/remko_ar715/remko_ar715.h"
namespace esphome::remko_ar715::testing {
static constexpr int32_t BIT_MARK = 511;
static constexpr int32_t HDR_SPACE = 3492;
static constexpr int32_t ONE_SPACE = 1540;
static constexpr int32_t ZERO_SPACE = 548;
static constexpr int BITS = 52;
/// The waveform the component must produce for a code: header, 52 bits MSB first, stop bit
static remote_base::RawTimings waveform(uint64_t code) {
remote_base::RawTimings raw;
raw.push_back(BIT_MARK);
raw.push_back(-HDR_SPACE);
for (int i = BITS - 1; i >= 0; i--) {
raw.push_back(BIT_MARK);
raw.push_back((code >> i) & 1 ? -ONE_SPACE : -ZERO_SPACE);
}
raw.push_back(BIT_MARK);
raw.push_back(-HDR_SPACE);
return raw;
}
/// Decodes a frame back into its code so a mismatch is readable
static uint64_t code_of(const remote_base::RawTimings &raw) {
uint64_t code = 0;
for (int bit = 0; bit < BITS; bit++) {
const size_t space = 3 + 2 * bit;
code = (code << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0);
}
return code;
}
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
std::vector<remote_base::RawTimings> frames;
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override {
this->frames.push_back(this->temp_.get_data());
}
};
struct Fixture {
CapturingTransmitter transmitter;
RemkoAr715Climate climate;
Fixture() {
this->climate.set_transmitter(&this->transmitter);
this->climate.setup();
}
void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan,
climate::ClimateSwingMode swing) {
auto call = this->climate.make_call();
call.set_mode(mode);
call.set_target_temperature(temperature);
call.set_fan_mode(fan);
call.set_swing_mode(swing);
call.perform();
}
const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); }
};
static void expect_code(const remote_base::RawTimings &actual, uint64_t code) {
EXPECT_EQ(actual, waveform(code)) << std::hex << "actual 0x" << code_of(actual) << "\nexpected 0x" << code;
}
// Codes captured from the AR-715 remote in IRremoteESP8266 issue 1812
TEST(RemkoAr715, EncodesCapturedFrames) {
Fixture f;
f.command(climate::CLIMATE_MODE_COOL, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL);
expect_code(f.last_frame(), 0x830000005880FULL);
// Power off keeps the rest of the state in the frame
f.command(climate::CLIMATE_MODE_OFF, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL);
expect_code(f.last_frame(), 0x8000000058802ULL);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF);
expect_code(f.last_frame(), 0x830000007919AULL);
f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF);
expect_code(f.last_frame(), 0x830000007914FULL);
}
// Dry and fan-only replace the fan nibbles; the fan speed is not sent
TEST(RemkoAr715, DryAndFanOnlyIgnoreTheFanSpeed) {
Fixture f;
f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF);
expect_code(f.last_frame(), 0x830000007A469ULL);
f.command(climate::CLIMATE_MODE_FAN_ONLY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF);
expect_code(f.last_frame(), 0x830000007A36AULL);
}
} // namespace esphome::remko_ar715::testing
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
remko_ar715: !include common.yaml
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
remko_ar715: !include common.yaml