From 85205a28d283583d72c2681f89f37c0b2d28f8fa Mon Sep 17 00:00:00 2001 From: aanban Date: Tue, 28 Oct 2025 03:49:16 +0100 Subject: [PATCH] [remote_base] add support for Dyson cool AM07 tower fan (#10163) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/remote_base/__init__.py | 44 ++++++++++++ .../components/remote_base/dyson_protocol.cpp | 71 +++++++++++++++++++ .../components/remote_base/dyson_protocol.h | 46 ++++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_transmitter/common-buttons.yaml | 7 ++ 5 files changed, 173 insertions(+) create mode 100644 esphome/components/remote_base/dyson_protocol.cpp create mode 100644 esphome/components/remote_base/dyson_protocol.h diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index ccf16a8beb..8d735ea563 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_FAMILY, CONF_GROUP, CONF_ID, + CONF_INDEX, CONF_INVERTED, CONF_LEVEL, CONF_MAGNITUDE, @@ -616,6 +617,49 @@ async def dooya_action(var, config, args): cg.add(var.set_check(template_)) +# Dyson +DysonData, DysonBinarySensor, DysonTrigger, DysonAction, DysonDumper = declare_protocol( + "Dyson" +) +DYSON_SCHEMA = cv.Schema( + { + cv.Required(CONF_CODE): cv.hex_uint16_t, + cv.Optional(CONF_INDEX, default=0xFF): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("dyson", DysonBinarySensor, DYSON_SCHEMA) +def dyson_binary_sensor(var, config): + cg.add( + var.set_data( + cg.StructInitializer( + DysonData, + ("code", config[CONF_CODE]), + ("index", config[CONF_INDEX]), + ) + ) + ) + + +@register_trigger("dyson", DysonTrigger, DysonData) +def dyson_trigger(var, config): + pass + + +@register_dumper("dyson", DysonDumper) +def dyson_dumper(var, config): + pass + + +@register_action("dyson", DysonAction, DYSON_SCHEMA) +async def dyson_action(var, config, args): + template_ = await cg.templatable(config[CONF_CODE], args, cg.uint16) + cg.add(var.set_code(template_)) + template_ = await cg.templatable(config[CONF_INDEX], args, cg.uint8) + cg.add(var.set_index(template_)) + + # JVC JVCData, JVCBinarySensor, JVCTrigger, JVCAction, JVCDumper = declare_protocol("JVC") JVC_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint32_t}) diff --git a/esphome/components/remote_base/dyson_protocol.cpp b/esphome/components/remote_base/dyson_protocol.cpp new file mode 100644 index 0000000000..db4e1135f4 --- /dev/null +++ b/esphome/components/remote_base/dyson_protocol.cpp @@ -0,0 +1,71 @@ +#include "dyson_protocol.h" +#include "esphome/core/log.h" + +#include + +namespace esphome { +namespace remote_base { + +static const char *const TAG = "remote.dyson"; + +// pulsewidth [µs] +constexpr uint32_t PW_MARK_US = 780; +constexpr uint32_t PW_SHORT_US = 720; +constexpr uint32_t PW_LONG_US = 1500; +constexpr uint32_t PW_START_US = 2280; + +// MSB of 15 bit dyson code +constexpr uint16_t MSB_DYSON = (1 << 14); + +// required symbols in transmit buffer = (start_symbol + 15 data_symbols) +constexpr uint32_t N_SYMBOLS_REQ = 2u * (1 + 15); + +void DysonProtocol::encode(RemoteTransmitData *dst, const DysonData &data) { + uint32_t raw_code = (data.code << 2) + (data.index & 3); + dst->set_carrier_frequency(36000); + dst->reserve(N_SYMBOLS_REQ + 1); + dst->item(PW_START_US, PW_SHORT_US); + for (uint16_t mask = MSB_DYSON; mask != 0; mask >>= 1) { + if (mask == (mask & raw_code)) { + dst->item(PW_MARK_US, PW_LONG_US); + } else { + dst->item(PW_MARK_US, PW_SHORT_US); + } + } + dst->mark(PW_MARK_US); // final carrier pulse +} + +optional DysonProtocol::decode(RemoteReceiveData src) { + uint32_t n_received = static_cast(src.size()); + uint16_t raw_code = 0; + DysonData data{ + .code = 0, + .index = 0, + }; + if (n_received < N_SYMBOLS_REQ) + return {}; // invalid frame length + if (!src.expect_item(PW_START_US, PW_SHORT_US)) + return {}; // start not found + for (uint16_t mask = MSB_DYSON; mask != 0; mask >>= 1) { + if (src.expect_item(PW_MARK_US, PW_SHORT_US)) { + raw_code &= ~mask; // zero detected + } else if (src.expect_item(PW_MARK_US, PW_LONG_US)) { + raw_code |= mask; // one detected + } else { + return {}; // invalid data item + } + } + data.code = raw_code >> 2; // extract button code + data.index = raw_code & 3; // extract rolling index + if (src.expect_mark(PW_MARK_US)) { // check total length + return data; + } + return {}; // frame not complete +} + +void DysonProtocol::dump(const DysonData &data) { + ESP_LOGI(TAG, "Dyson: code=0x%x rolling index=%d", data.code, data.index); +} + +} // namespace remote_base +} // namespace esphome diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h new file mode 100644 index 0000000000..d1c08fefba --- /dev/null +++ b/esphome/components/remote_base/dyson_protocol.h @@ -0,0 +1,46 @@ +#pragma once + +#include "remote_base.h" + +#include + +namespace esphome { +namespace remote_base { + +static constexpr uint8_t IGNORE_INDEX = 0xFF; + +struct DysonData { + uint16_t code; // the button, e.g. power, swing, fan++, ... + uint8_t index; // the rolling index counter + bool operator==(const DysonData &rhs) const { + if (IGNORE_INDEX == index || IGNORE_INDEX == rhs.index) { + return code == rhs.code; + } + return code == rhs.code && index == rhs.index; + } +}; + +class DysonProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const DysonData &data) override; + optional decode(RemoteReceiveData src) override; + void dump(const DysonData &data) override; +}; + +DECLARE_REMOTE_PROTOCOL(Dyson) + +template class DysonAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, code) + TEMPLATABLE_VALUE(uint8_t, index) + + void encode(RemoteTransmitData *dst, Ts... x) override { + DysonData data{}; + data.code = this->code_.value(x...); + data.index = this->index_.value(x...); + DysonProtocol().encode(dst, data); + } +}; + +} // namespace remote_base +} // namespace esphome diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index c2dc2f0c29..de01fa3602 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -48,6 +48,11 @@ on_drayton: - logger.log: format: "on_drayton: %u %u %u" args: ["x.address", "x.channel", "x.command"] +on_dyson: + then: + - logger.log: + format: "on_dyson: %u %u" + args: ["x.code", "x.index"] on_gobox: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 58127d1ab4..e9593cc97c 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -6,6 +6,13 @@ button: remote_transmitter.transmit_beo4: source: 0x01 command: 0x0C + - platform: template + name: Dyson fan up + id: dyson_fan_up + on_press: + remote_transmitter.transmit_dyson: + code: 0x1215 + index: 0x0 - platform: template name: JVC Off id: living_room_lights_on