From cfb7d8089e3a4fcbc1166bdcec2d95e1e9174712 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:42:21 +0000 Subject: [PATCH] [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 --- esphome/components/remote_base/__init__.py | 57 ++++++++++++- .../remote_base/hob2hood_protocol.cpp | 83 +++++++++++++++++++ .../remote_base/hob2hood_protocol.h | 39 +++++++++ tests/components/remote_base/__init__.py | 14 ++++ .../components/remote_base/hob2hood_test.cpp | 83 +++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_receiver/esp32-common.yaml | 4 + .../remote_transmitter/common-buttons.yaml | 5 ++ 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/hob2hood_protocol.cpp create mode 100644 esphome/components/remote_base/hob2hood_protocol.h create mode 100644 tests/components/remote_base/__init__.py create mode 100644 tests/components/remote_base/hob2hood_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e542ba3ed8..befef872f0 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -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" diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp new file mode 100644 index 0000000000..9e706b9fe6 --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.cpp @@ -0,0 +1,83 @@ +#include "hob2hood_protocol.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +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 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 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 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 diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h new file mode 100644 index 0000000000..44bd873a65 --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -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 { + public: + void encode(RemoteTransmitData *dst, const Hob2HoodData &data); + optional decode(RemoteReceiveData src); + void dump(const Hob2HoodData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Hob2Hood) + +template class Hob2HoodAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(Hob2HoodCommand, command) + void encode(RemoteTransmitData *dst, Ts... x) override { + Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); + } +}; + +} // namespace esphome::remote_base diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py new file mode 100644 index 0000000000..714f8ab571 --- /dev/null +++ b/tests/components/remote_base/__init__.py @@ -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"] diff --git a/tests/components/remote_base/hob2hood_test.cpp b/tests/components/remote_base/hob2hood_test.cpp new file mode 100644 index 0000000000..223e15fe49 --- /dev/null +++ b/tests/components/remote_base/hob2hood_test.cpp @@ -0,0 +1,83 @@ +#include +#include "esphome/components/remote_base/hob2hood_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr std::array 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 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 diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 26a02d4dab..5d12dc68d4 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -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"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 14effcbd2c..956cfdc323 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,3 +15,7 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: Hob2Hood Light On + hob2hood: + command: light_on diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 1f98646d93..a41ba346aa 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -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