diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 0e0a63e9d94..abb216aad7d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, + CONF_CHANNELS, CONF_CHECK, CONF_CODE, CONF_COMMAND, @@ -71,6 +72,7 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" +CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2911,3 +2913,55 @@ register_protocol_action( (CONF_MODE, "mode", IRA211ModeEnum), (CONF_FAN_MODE, "fan", IRA211FanEnum), ) + + +# A-OK +( + AOKData, + AOKBinarySensor, + AOKTrigger, + _, + AOKDumper, +) = declare_protocol("AOK") + +AOK_SCHEMA = cv.Schema( + { + cv.Required(CONF_REMOTE_ID): cv.hex_int_range(0, 0xFFFFFF), + cv.Required(CONF_CHANNELS): cv.hex_uint16_t, + cv.Required(CONF_COMMAND): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("aok", AOKBinarySensor, AOK_SCHEMA) +def aok_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + AOKData, + ("remote_id", config[CONF_REMOTE_ID]), + ("channels", config[CONF_CHANNELS]), + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("aok", AOKTrigger, AOKData) +def aok_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("aok", AOKDumper) +def aok_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "aok", + "AOK", + AOK_SCHEMA, + (CONF_REMOTE_ID, "remote_id", cg.uint32), + (CONF_CHANNELS, "channels", cg.uint16), + (CONF_COMMAND, "command", cg.uint8), +) diff --git a/esphome/components/remote_base/aok_protocol.cpp b/esphome/components/remote_base/aok_protocol.cpp new file mode 100644 index 00000000000..e0abc1eb351 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.cpp @@ -0,0 +1,80 @@ +#include "aok_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.aok"); + +// A frame is a long start mark, then 64 bits, then a long gap. A one bit is a long mark and a short space, +// a zero bit is a short mark and a long space. The bits are: header (8), remote id (24), channels (16), +// command (8) and checksum (8), most significant bit first. +static constexpr uint8_t NBITS = 64; +static constexpr uint32_t START_HIGH_US = 5000; +static constexpr uint32_t LONG_US = 600; +static constexpr uint32_t SHORT_US = 285; +static constexpr uint8_t HEADER = 0xA3; +static constexpr uint32_t REMOTE_ID_MASK = 0xFFFFFF; + +// The sum of the six bytes between the header and the checksum +static uint8_t checksum(uint64_t frame) { + uint8_t sum = 0; + for (uint8_t shift = 8; shift < 56; shift += 8) { + sum += frame >> shift; + } + return sum; +} + +void AOKProtocol::encode(RemoteTransmitData *dst, const AOKData &data) { + uint64_t frame = (uint64_t(HEADER) << 56) | (uint64_t(data.remote_id & REMOTE_ID_MASK) << 32) | + (uint64_t(data.channels) << 16) | (uint64_t(data.command) << 8); + frame |= checksum(frame); + + dst->reserve((NBITS + 2) * 2); + dst->item(START_HIGH_US, LONG_US); + for (uint64_t mask = 1ULL << (NBITS - 1); mask; mask >>= 1) { + if (frame & mask) { + dst->item(LONG_US, SHORT_US); + } else { + dst->item(SHORT_US, LONG_US); + } + } + dst->item(LONG_US, START_HIGH_US); +} + +optional AOKProtocol::decode(RemoteReceiveData src) { + // A buffer can hold noise and several repeats of a frame, so look for the first good one. After any + // failure the loop restarts at the next timing, or where the failed frame stopped. + while (src.is_valid()) { + if (!src.expect_pulse_with_gap(START_HIGH_US, LONG_US)) { + src.advance(); + continue; + } + + uint64_t frame = 0; + uint8_t bits = 0; + for (; bits < NBITS; bits++) { + frame <<= 1; + if (src.expect_item(LONG_US, SHORT_US)) { + frame |= 1; + } else if (!src.expect_item(SHORT_US, LONG_US)) { + break; + } + } + + if (bits == NBITS && uint8_t(frame >> 56) == HEADER && uint8_t(frame) == checksum(frame)) { + return AOKData{ + .remote_id = uint32_t(frame >> 32) & REMOTE_ID_MASK, + .channels = uint16_t(frame >> 16), + .command = uint8_t(frame >> 8), + }; + } + } + return {}; +} + +void AOKProtocol::dump(const AOKData &data) { + ESP_LOGI(TAG, "Received A-OK: remote_id=0x%06" PRIX32 ", channels=0x%04X, command=0x%02X", data.remote_id, + data.channels, data.command); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aok_protocol.h b/esphome/components/remote_base/aok_protocol.h new file mode 100644 index 00000000000..19967fa40b6 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.h @@ -0,0 +1,26 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct AOKData { + uint32_t remote_id; // 24 bits + uint16_t channels; + uint8_t command; + + bool operator==(const AOKData &rhs) const { + return this->remote_id == rhs.remote_id && this->channels == rhs.channels && this->command == rhs.command; + } +}; + +class AOKProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const AOKData &data); + optional decode(RemoteReceiveData src); + void dump(const AOKData &data); +}; + +DECLARE_REMOTE_PROTOCOL(AOK) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a2480108a72..13dc3492f29 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME #define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_AOK #define USE_REMOTE_PROTOCOL_BEO4 #define USE_REMOTE_PROTOCOL_BRENNENSTUHL #define USE_REMOTE_PROTOCOL_BYRONSX diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index f1045572656..fed957d3378 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -12,6 +12,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "haier", "mirage", "aeha", + "aok", "keeloq", "nexus", "ira211", diff --git a/tests/components/remote_base/aok_test.cpp b/tests/components/remote_base/aok_test.cpp new file mode 100644 index 00000000000..d4fd33d0992 --- /dev/null +++ b/tests/components/remote_base/aok_test.cpp @@ -0,0 +1,135 @@ +#include + +#include "esphome/components/remote_base/aok_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint64_t FRAME = 0xA312345600A10B48ULL; +// The start mark and space, then 64 bits of a mark and a space each, then the closing mark and space +constexpr size_t TIMINGS_SIZE = (1 + 64 + 1) * 2; + +RawTimings encode(const AOKData &data) { + RemoteTransmitData tx; + AOKProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return AOKProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +// Bit `n` counted from the first one sent starts at timing 2 + 2 * n +RawTimings frame_timings(uint64_t frame) { + RawTimings timings{5000, -600}; + for (int i = 63; i >= 0; i--) { + if ((frame >> i) & 1) { + timings.insert(timings.end(), {600, -285}); + } else { + timings.insert(timings.end(), {285, -600}); + } + } + timings.insert(timings.end(), {600, -5000}); + return timings; +} + +} // namespace + +TEST(AOKProtocolTest, EncodesKnownFrame) { + EXPECT_EQ(encode(AOKData{.remote_id = 0x123456, .channels = 0x00A1, .command = 0x0B}), frame_timings(FRAME)); +} + +TEST(AOKProtocolTest, DecodesKnownFrame) { + auto decoded = decode(frame_timings(FRAME)); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->channels, 0x00A1); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, RoundTrips) { + for (const AOKData &sent : + {AOKData{0x000000, 0x0000, 0x00}, AOKData{0xFFFFFF, 0xFFFF, 0xFF}, AOKData{0xABCDEF, 0x8001, 0x7E}}) { + const RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), TIMINGS_SIZE); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_TRUE(*decoded == sent); + } +} + +TEST(AOKProtocolTest, EncodingKeepsRemoteIdToItsFieldWidth) { + auto decoded = decode(encode(AOKData{.remote_id = 0xFF123456, .channels = 0x00A1, .command = 0x0B})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); +} + +TEST(AOKProtocolTest, RejectsBadChecksum) { EXPECT_FALSE(decode(frame_timings(FRAME ^ 1)).has_value()); } + +TEST(AOKProtocolTest, RejectsBadHeader) { + // The header is not part of the checksum, so only the header check can reject this + EXPECT_FALSE(decode(frame_timings(FRAME ^ (1ULL << 56))).has_value()); +} + +TEST(AOKProtocolTest, SkipsLeadingNoise) { + RawTimings timings{300, -1200, 150, -90}; + const RawTimings frame = frame_timings(FRAME); + timings.insert(timings.end(), frame.begin(), frame.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCorrupt) { + RawTimings timings = frame_timings(FRAME ^ 1); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCutShort) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 40); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + EXPECT_TRUE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadStart) { + RawTimings timings = frame_timings(FRAME); + timings[0] = 2000; + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 6); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadBitTiming) { + RawTimings timings = frame_timings(FRAME); + timings[2 + 2 * 20] = 1200; + EXPECT_FALSE(decode(timings).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 da88548d2ab..833de19ff4f 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -187,6 +187,11 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_aok: + then: + - logger.log: + format: "on_aok: %u %u %u" + args: ["x.remote_id", "x.channels", "x.command"] on_hob2hood: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 66ee803d4f9..f9f1beceae9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -395,6 +395,23 @@ button: repeat: times: 3 wait_time: 10ms + - platform: template + name: A-OK + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: 0x00A1 + command: 0x0B + - platform: template + name: A-OK lambda with repeat + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: !lambda return 0x1000; + command: 0x43 + repeat: + times: 8 + wait_time: 5ms - platform: template name: Toto with repeat on_press: