[remote_base] Add AEG Hob2Hood Infrared Protocol (#4687)

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:
Mat931
2026-09-26 00:42:21 +01:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston
parent bfe1f7bd17
commit cfb7d8089e
8 changed files with 289 additions and 1 deletions
+56 -1
View File
@@ -47,7 +47,7 @@ from esphome.const import (
from esphome.core import ID, coroutine
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.types import ConfigType, TemplateArgsType
from esphome.util import Registry, SimpleRegistry
AUTO_LOAD = ["binary_sensor"]
@@ -2106,6 +2106,61 @@ async def aeha_action(var, config, args):
cg.add(var.set_carrier_frequency(templ))
# Hob2Hood
(
Hob2HoodData,
Hob2HoodBinarySensor,
Hob2HoodTrigger,
Hob2HoodAction,
Hob2HoodDumper,
) = declare_protocol("Hob2Hood")
Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand")
HOB2HOOD_COMMAND_OPTIONS = {
"light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF,
"light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON,
"fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF,
"fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW,
"fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM,
"fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH,
"fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX,
}
HOB2HOOD_SCHEMA = cv.Schema(
{cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)}
)
@register_binary_sensor("hob2hood", Hob2HoodBinarySensor, HOB2HOOD_SCHEMA)
def hob2hood_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
Hob2HoodData,
("command", config[CONF_COMMAND]),
)
)
)
@register_trigger("hob2hood", Hob2HoodTrigger, Hob2HoodData)
def hob2hood_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("hob2hood", Hob2HoodDumper)
def hob2hood_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA)
async def hob2hood_action(
var: MockObj, config: ConfigType, args: TemplateArgsType
) -> None:
template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand)
cg.add(var.set_command(template_))
# Haier
HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol(
"Haier"
@@ -0,0 +1,83 @@
#include "hob2hood_protocol.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <array>
#include <cstdlib>
namespace esphome::remote_base {
static const char *const TAG = "remote.hob2hood";
// A frame is 25 bits: a leading 0, then the command byte, command + 1 and command + 2. Zero bits are marks
// and one bits are spaces; equal neighbours merge into one run of n * BIT_TIME_US plus a fixed adjustment.
static constexpr uint8_t NBITS = 25;
static constexpr uint32_t BIT_TIME_US = 700;
static constexpr int32_t MARK_ADJUST_US = 300;
static constexpr int32_t SPACE_ADJUST_US = -200;
// The longest frame (light_off) has 18 runs
static constexpr uint8_t MAX_RUNS = 18;
static constexpr std::array<Hob2HoodCommand, 7> COMMANDS = {
HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW,
HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX,
};
// Same order as COMMANDS; the last entry is the fallback
PROGMEM_STRING_TABLE(Hob2HoodCommandNames, "light_off", "light_on", "fan_off", "fan_low", "fan_medium", "fan_high",
"fan_max", "unknown");
// Walks the frame of `command` as runs of equal bits. emit(is_mark, length_us, is_last) returns false to stop.
template<typename F> static bool walk_runs(Hob2HoodCommand command, F &&emit) {
// Shifted so the first of the 25 bits is the top bit
uint32_t bits = ((uint32_t(command) << 16) | (uint32_t(uint8_t(command + 1)) << 8) | uint8_t(command + 2))
<< (32 - NBITS);
int8_t run = 0;
for (uint8_t i = 0; i < NBITS; i++, bits <<= 1) {
const bool bit = (bits & 0x80000000) != 0;
run += bit ? 1 : -1;
const bool last = i == NBITS - 1;
if (last || (((bits << 1) & 0x80000000) != 0) != bit) {
const uint32_t length = BIT_TIME_US * std::abs(run) + (run < 0 ? MARK_ADJUST_US : SPACE_ADJUST_US);
if (!emit(run < 0, length, last))
return false;
run = 0;
}
}
return true;
}
void Hob2HoodProtocol::encode(RemoteTransmitData *dst, const Hob2HoodData &data) {
dst->set_carrier_frequency(38000);
dst->reserve(MAX_RUNS);
walk_runs(data.command, [dst](bool is_mark, uint32_t length, bool) {
if (is_mark) {
dst->mark(length);
} else {
dst->space(length);
}
return true;
});
}
optional<Hob2HoodData> Hob2HoodProtocol::decode(RemoteReceiveData src) {
for (auto command : COMMANDS) {
src.reset();
// The receiver does not capture a trailing space, so the last run only has to match when it is a mark
const bool matched = walk_runs(command, [&src](bool is_mark, uint32_t length, bool last) {
return is_mark ? src.expect_mark(length) : (last || src.expect_space(length));
});
if (matched)
return Hob2HoodData{command};
}
return {};
}
void Hob2HoodProtocol::dump(const Hob2HoodData &data) {
uint8_t index = 0;
while (index < COMMANDS.size() && COMMANDS[index] != data.command)
index++;
ESP_LOGI(TAG, "Received Hob2Hood: %s",
LOG_STR_ARG(Hob2HoodCommandNames::get_log_str(index, Hob2HoodCommandNames::LAST_INDEX)));
}
} // namespace esphome::remote_base
@@ -0,0 +1,39 @@
#pragma once
#include "remote_base.h"
namespace esphome::remote_base {
enum Hob2HoodCommand : uint8_t {
HOB2HOOD_COMMAND_LIGHT_OFF = 0xd5,
HOB2HOOD_COMMAND_LIGHT_ON = 0xd2,
HOB2HOOD_COMMAND_FAN_OFF = 0xd8,
HOB2HOOD_COMMAND_FAN_LOW = 0x6c,
HOB2HOOD_COMMAND_FAN_MEDIUM = 0x6f,
HOB2HOOD_COMMAND_FAN_HIGH = 0xe1,
HOB2HOOD_COMMAND_FAN_MAX = 0x72,
};
struct Hob2HoodData {
Hob2HoodCommand command;
bool operator==(const Hob2HoodData &rhs) const { return this->command == rhs.command; }
};
class Hob2HoodProtocol : public RemoteProtocol<Hob2HoodData> {
public:
void encode(RemoteTransmitData *dst, const Hob2HoodData &data);
optional<Hob2HoodData> decode(RemoteReceiveData src);
void dump(const Hob2HoodData &data);
};
DECLARE_REMOTE_PROTOCOL(Hob2Hood)
template<typename... Ts> class Hob2HoodAction : public RemoteTransmitterActionBase<Ts...> {
public:
TEMPLATABLE_VALUE(Hob2HoodCommand, command)
void encode(RemoteTransmitData *dst, Ts... x) override {
Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)});
}
};
} // namespace esphome::remote_base
+14
View File
@@ -0,0 +1,14 @@
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
from esphome.components.remote_base import request_protocol
async def to_code_testing(config: ConfigType) -> None:
# Protocol sources are compiled only behind their define; keep the ones under test.
request_protocol("hob2hood")
manifest.to_code = to_code_testing
# AUTO_LOAD is not resolved by the unit test build.
manifest.dependencies = manifest.dependencies + ["binary_sensor"]
@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include "esphome/components/remote_base/hob2hood_protocol.h"
namespace esphome::remote_base::testing {
namespace {
constexpr std::array<Hob2HoodCommand, 7> ALL_COMMANDS = {
HOB2HOOD_COMMAND_LIGHT_OFF, HOB2HOOD_COMMAND_LIGHT_ON, HOB2HOOD_COMMAND_FAN_OFF, HOB2HOOD_COMMAND_FAN_LOW,
HOB2HOOD_COMMAND_FAN_MEDIUM, HOB2HOOD_COMMAND_FAN_HIGH, HOB2HOOD_COMMAND_FAN_MAX,
};
RawTimings encode(Hob2HoodCommand command) {
RemoteTransmitData data;
Hob2HoodProtocol().encode(&data, Hob2HoodData{command});
return data.get_data();
}
optional<Hob2HoodData> decode(const RawTimings &timings, uint32_t tolerance = 25,
ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) {
return Hob2HoodProtocol().decode(RemoteReceiveData(timings, tolerance, mode));
}
void expect_decodes_to(const RawTimings &timings, Hob2HoodCommand command, uint32_t tolerance = 25,
ToleranceMode mode = TOLERANCE_MODE_PERCENTAGE) {
auto decoded = decode(timings, tolerance, mode);
ASSERT_TRUE(decoded.has_value()) << "command 0x" << std::hex << int(command);
// clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check
if (decoded.has_value()) {
EXPECT_EQ(decoded->command, command);
}
}
} // namespace
// light_on is 0xd2: the bits 0 11010010 11010011 11010100 form 17 runs. A run of n zero bits is a mark of
// n * 700 + 300 us; a run of n one bits is a space of n * 700 - 200 us.
TEST(Hob2HoodProtocolTest, EncodesTheDocumentedTimings) {
const RawTimings expected = {1000, -1200, 1000, -500, 1700, -500, 1000, -1200, 1000,
-500, 1700, -2600, 1000, -500, 1000, -500, 1700};
EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_ON), expected);
}
TEST(Hob2HoodProtocolTest, LongestFrameFitsTheReservedLength) {
for (auto command : ALL_COMMANDS) {
EXPECT_LE(encode(command).size(), 18u) << "command 0x" << std::hex << int(command);
}
EXPECT_EQ(encode(HOB2HOOD_COMMAND_LIGHT_OFF).size(), 18u);
}
TEST(Hob2HoodProtocolTest, RoundTripsEveryCommand) {
for (auto command : ALL_COMMANDS) {
expect_decodes_to(encode(command), command);
}
}
// A receiver never captures the trailing space, so a frame that ends in one must still decode without it.
TEST(Hob2HoodProtocolTest, DecodesWithoutTheTrailingSpace) {
for (auto command : ALL_COMMANDS) {
auto timings = encode(command);
if (timings.back() < 0)
timings.pop_back();
expect_decodes_to(timings, command);
}
}
// Real receivers shorten marks and lengthen spaces by a couple of hundred microseconds, which is why the
// documentation recommends a 350us tolerance.
TEST(Hob2HoodProtocolTest, DecodesSkewedTimingsWithinTheRecommendedTolerance) {
auto timings = encode(HOB2HOOD_COMMAND_FAN_HIGH);
for (auto &t : timings) {
t += t > 0 ? -240 : -230;
}
expect_decodes_to(timings, HOB2HOOD_COMMAND_FAN_HIGH, 350, TOLERANCE_MODE_TIME);
}
TEST(Hob2HoodProtocolTest, RejectsAForeignFrame) {
const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560};
EXPECT_FALSE(decode(nec_like).has_value());
EXPECT_FALSE(decode({}).has_value());
}
} // namespace esphome::remote_base::testing
@@ -172,3 +172,8 @@ on_toto:
- logger.log:
format: "on_toto: %u %u %u"
args: ["x.rc_code_1", "x.rc_code_2", "x.command"]
on_hob2hood:
then:
- logger.log:
format: "on_hob2hood: %u"
args: ["x.command"]
@@ -15,3 +15,7 @@ binary_sensor:
panasonic:
address: 0x4004
command: 0x100BCBD
- platform: remote_receiver
name: Hob2Hood Light On
hob2hood:
command: light_on
@@ -278,3 +278,8 @@ button:
- remote_transmitter.digital_write:
transmitter_id: xmitr
value: !lambda return id(remote_transmitter_test_number).state > 127;
- platform: template
name: Hob2Hood
on_press:
- remote_transmitter.transmit_hob2hood:
command: light_on