mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 15:30:21 +00:00
[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:
co-authored by
pre-commit-ci-lite[bot]
J. Nick Koston
parent
bfe1f7bd17
commit
cfb7d8089e
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user