From 1c71adb6cae8a8a2a32d8129a05f5a851a43c14c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:05:20 +0000 Subject: [PATCH] test: Create `zwave_proxy_tap` --- .../components/zwave_proxy_tap/__init__.py | 47 +++++ .../zwave_proxy_tap/zwave_detector.cpp | 166 ++++++++++++++++++ .../zwave_proxy_tap/zwave_detector.h | 121 +++++++++++++ .../zwave_proxy_tap/zwave_protocol.h | 33 ++++ .../zwave_proxy_tap/zwave_proxy_tap.cpp | 62 +++++++ .../zwave_proxy_tap/zwave_proxy_tap.h | 53 ++++++ esphome/core/defines.h | 1 + tests/components/zwave_proxy_tap/.gitignore | 5 + tests/components/zwave_proxy_tap/common.yaml | 16 ++ .../test-usb.esp32-s3-idf.yaml | 27 +++ .../zwave_proxy_tap/test.esp32-idf.yaml | 3 + .../zwave_proxy_tap/test.esp8266-ard.yaml | 3 + .../zwave_proxy_tap/test.rp2040-ard.yaml | 3 + 13 files changed, 540 insertions(+) create mode 100644 esphome/components/zwave_proxy_tap/__init__.py create mode 100644 esphome/components/zwave_proxy_tap/zwave_detector.cpp create mode 100644 esphome/components/zwave_proxy_tap/zwave_detector.h create mode 100644 esphome/components/zwave_proxy_tap/zwave_protocol.h create mode 100644 esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp create mode 100644 esphome/components/zwave_proxy_tap/zwave_proxy_tap.h create mode 100644 tests/components/zwave_proxy_tap/.gitignore create mode 100644 tests/components/zwave_proxy_tap/common.yaml create mode 100644 tests/components/zwave_proxy_tap/test-usb.esp32-s3-idf.yaml create mode 100644 tests/components/zwave_proxy_tap/test.esp32-idf.yaml create mode 100644 tests/components/zwave_proxy_tap/test.esp8266-ard.yaml create mode 100644 tests/components/zwave_proxy_tap/test.rp2040-ard.yaml diff --git a/esphome/components/zwave_proxy_tap/__init__.py b/esphome/components/zwave_proxy_tap/__init__.py new file mode 100644 index 0000000000..d87cd39d8a --- /dev/null +++ b/esphome/components/zwave_proxy_tap/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import serial_proxy +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["serial_proxy"] + +CONF_SERIAL_PROXY_ID = "serial_proxy_id" + +zwave_proxy_tap_ns = cg.esphome_ns.namespace("zwave_proxy_tap") +ZWaveProxyTap = zwave_proxy_tap_ns.class_( + "ZWaveProxyTap", cg.Component, serial_proxy.SerialProxyTap +) + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + if (wifi_conf := full_config.get(CONF_WIFI)) and ( + wifi_conf.get(CONF_POWER_SAVE_MODE, "").lower() != "none" + ): + raise cv.Invalid( + f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy" + ) + return config + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ZWaveProxyTap), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), + } +).extend(cv.COMPONENT_SCHEMA) + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) + + cg.add_define("USE_ZWAVE_PROXY_TAP") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") diff --git a/esphome/components/zwave_proxy_tap/zwave_detector.cpp b/esphome/components/zwave_proxy_tap/zwave_detector.cpp new file mode 100644 index 0000000000..1408d58407 --- /dev/null +++ b/esphome/components/zwave_proxy_tap/zwave_detector.cpp @@ -0,0 +1,166 @@ +#include "zwave_detector.h" + +#ifdef USE_ZWAVE_PROXY_TAP + +namespace esphome::zwave_proxy_tap { + +// Consecutive malformed frames, with no well-formed one in between, before concluding the +// controller is no longer speaking the Serial API. Repeated checksum failures mean our +// idea of where frames begin is wrong, and acknowledging frames we are misreading is +// worse than acknowledging none, so the safe move is to stop and wait to be convinced +// again. A well-formed frame is the evidence that clears the suspicion; garbage is not, +// since noise proves nothing either way. +static constexpr uint8_t MAX_UNCONFIRMED_REJECTS = 4; + +// Abandons a frame that stalled part-received, and time-stamps the batch about to be fed. +static void expire_stalled_frame(ZWaveFrameScanner &scanner, uint32_t &frame_start, uint32_t now) { + if (scanner.in_frame()) { + if (now - frame_start <= ZWAVE_FRAME_TIMEOUT_MS) { + return; // Still within its window; keep the start time it already has + } + scanner.reset(); + } + frame_start = now; +} + +ScanResult ZWaveFrameScanner::feed(uint8_t byte) { + switch (this->state_) { + case ScanState::WAIT_SOF: + // ACK/NAK/CAN and anything else carry no framing, so there is nothing to reassemble + if (byte == ZWAVE_SOF_BYTE) { + this->state_ = ScanState::WAIT_LENGTH; + } + return ScanResult::NONE; + + case ScanState::WAIT_LENGTH: + if (byte < ZWAVE_MIN_LENGTH) { + // Not a length the protocol can produce. A 0x01 in this position is far more + // likely to be the real start of a frame than a length, so treat it as one. + this->state_ = byte == ZWAVE_SOF_BYTE ? ScanState::WAIT_LENGTH : ScanState::WAIT_SOF; + return ScanResult::INVALID; + } + this->remaining_ = byte; + this->checksum_ = ZWAVE_CHECKSUM_INIT ^ byte; + this->state_ = ScanState::WAIT_TYPE; + return ScanResult::NONE; + + case ScanState::WAIT_TYPE: + this->type_ = byte; + this->checksum_ ^= byte; + this->remaining_--; + this->state_ = ScanState::WAIT_COMMAND; + return ScanResult::NONE; + + case ScanState::WAIT_COMMAND: + this->command_ = byte; + this->checksum_ ^= byte; + this->remaining_--; + this->state_ = ScanState::WAIT_BODY; + return ScanResult::NONE; + + case ScanState::WAIT_BODY: + break; + } + + if (this->remaining_ > 1) { + this->checksum_ ^= byte; + this->remaining_--; + return ScanResult::NONE; + } + // The frame's last byte is its checksum, which the accumulator can be compared against + // directly -- everything it covers has already been folded in. + this->state_ = ScanState::WAIT_SOF; + return byte == this->checksum_ ? ScanResult::FRAME : ScanResult::INVALID; +} + +void ZWaveDetector::reset() { + this->device_scanner_.reset(); + this->host_scanner_.reset(); + this->state_ = ZWaveDetectState::IDLE; + this->pending_command_ = 0; + this->ack_owed_ = false; + this->unconfirmed_rejects_ = 0; +} + +void ZWaveDetector::begin_batch(uint32_t now) { + // Both directions, from either caller: a frame stalled in the quiet direction still has + // to expire, and the direction being fed is by definition not stalled. + expire_stalled_frame(this->device_scanner_, this->device_frame_start_, now); + expire_stalled_frame(this->host_scanner_, this->host_frame_start_, now); +} + +void ZWaveDetector::from_device(uint8_t byte) { + switch (this->device_scanner_.feed(byte)) { + case ScanResult::FRAME: + this->handle_device_frame_(); + break; + case ScanResult::INVALID: + // While armed this may be a corrupted frame, which the controller will retransmit, + // or a sign it stopped speaking the Serial API. reject_() distinguishes the two by + // whether a well-formed frame ever follows. + this->reject_(); + break; + case ScanResult::NONE: + break; + } +} + +void ZWaveDetector::handle_device_frame_() { + if (this->state_ == ZWaveDetectState::SAW_REQUEST) { + // An unsolicited request from the controller can arrive before the response we are + // waiting for; it is not the other half of the exchange, so it proves nothing. + if (this->device_scanner_.type() != ZWAVE_FRAME_TYPE_RESPONSE || + this->device_scanner_.command() != this->pending_command_) { + return; + } + this->state_ = ZWaveDetectState::ARMED; + // The host direction stops being scanned from here, so leave nothing part-read behind + this->host_scanner_.reset(); + } else if (this->state_ != ZWaveDetectState::ARMED) { + return; + } + + // Every well-formed frame is acknowledged, the one that armed us included: the + // controller is already waiting on that one, so answering now saves a retransmit. + this->ack_owed_ = true; + this->unconfirmed_rejects_ = 0; +} + +void ZWaveDetector::reject_() { + if (this->state_ != ZWaveDetectState::ARMED) { + return; + } + if (++this->unconfirmed_rejects_ >= MAX_UNCONFIRMED_REJECTS) { + this->state_ = ZWaveDetectState::IDLE; + this->unconfirmed_rejects_ = 0; + this->ack_owed_ = false; + } +} + +void ZWaveDetector::from_host(uint8_t byte) { + if (this->host_scanner_.feed(byte) != ScanResult::FRAME) { + return; + } + if (this->state_ == ZWaveDetectState::ARMED) { + return; + } + // Only a request opens an exchange. A later request replaces the one being waited on: + // the host does not repeat a command it has given up on. + if (this->host_scanner_.type() != ZWAVE_FRAME_TYPE_REQUEST) { + return; + } + this->pending_command_ = this->host_scanner_.command(); + this->state_ = ZWaveDetectState::SAW_REQUEST; +} + +bool ZWaveDetector::take_pending_ack() { + if (!this->ack_owed_) { + return false; + } + this->ack_owed_ = false; + return true; +} + +} // namespace esphome::zwave_proxy_tap + +#endif // USE_ZWAVE_PROXY_TAP diff --git a/esphome/components/zwave_proxy_tap/zwave_detector.h b/esphome/components/zwave_proxy_tap/zwave_detector.h new file mode 100644 index 0000000000..2d961c7a29 --- /dev/null +++ b/esphome/components/zwave_proxy_tap/zwave_detector.h @@ -0,0 +1,121 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ZWAVE_PROXY_TAP + +#include "zwave_protocol.h" + +#include + +namespace esphome::zwave_proxy_tap { + +// Decides when it is safe to acknowledge controller frames on a client's behalf. +// +// The client suppresses its own ACKs, so nobody else will send them, and injecting a +// stray 0x06 into a stream that is not the Serial API would corrupt it. Detection is +// therefore one-sided: arm only on evidence that cannot arise by accident, and never on +// frame validity alone, which other traffic can satisfy by luck. +// +// The Serial API has no fixed opening handshake to key off, but every session is a +// sequence of request/response exchanges, and one of those is evidence enough: +// +// request (host -> ctrl) 01 00 +// response (ctrl -> host) 01 01 +// +// Requiring a well-formed request and then a well-formed response carrying the same +// command, in opposite directions, cannot be satisfied by a unidirectional byte stream +// whatever it contains -- which is exactly the situation during a firmware upload. It +// also rules out the bootloader, which only ever emits single bytes and menu text and +// never a 0x01-framed multi-byte reply. Keying on the exchange rather than on one +// particular command means it does not matter which command the client opens with. +// +// Getting it wrong in the other direction is cheap: a frame we decline to acknowledge is +// retransmitted by the controller once its ack timeout expires, so we see a clean copy +// and lose only that delay. That asymmetry is why this errs towards silence everywhere, +// including on a bad checksum -- where the zwave_proxy component answers with a NAK, this +// says nothing and lets the timeout do the work. + +enum class ZWaveDetectState : uint8_t { + IDLE, // Not the Serial API, or not yet proven to be + SAW_REQUEST, // Exchange half-complete; watching for the matching response + ARMED, // Session confirmed; acknowledging on the client's behalf +}; + +enum class ScanResult : uint8_t { + NONE, // Mid-frame, or a byte that carried nothing + FRAME, // type()/command() describe a complete frame with a verified checksum + INVALID, // A frame started but was not well formed +}; + +// Reassembles one direction of the byte stream into checksum-verified frames. +// +// Because the framing is length-prefixed, the length is known before the payload +// arrives, so the checksum can be folded in byte by byte and nothing needs to be +// buffered. Only the two header fields the detector actually reads are kept, which is +// what makes a scanner per direction cost a handful of bytes rather than 257 each. +class ZWaveFrameScanner { + public: + ScanResult feed(uint8_t byte); + void reset() { this->state_ = ScanState::WAIT_SOF; } + + /// True while a frame is part-received, so the caller can time it out. + bool in_frame() const { return this->state_ != ScanState::WAIT_SOF; } + + // Valid only for the frame the last feed() reported. + uint8_t type() const { return this->type_; } + uint8_t command() const { return this->command_; } + + private: + enum class ScanState : uint8_t { + WAIT_SOF, + WAIT_LENGTH, + WAIT_TYPE, + WAIT_COMMAND, + WAIT_BODY, // Payload bytes, then the checksum that ends the frame + }; + + ScanState state_{ScanState::WAIT_SOF}; + uint8_t remaining_{0}; // Bytes of the current frame still to come, checksum included + uint8_t checksum_{ZWAVE_CHECKSUM_INIT}; + uint8_t type_{0}; + uint8_t command_{0}; +}; + +class ZWaveDetector { + public: + void reset(); + + /// Time-stamp a batch of observed bytes, before feeding them, so a frame left + /// part-received by an earlier batch is abandoned rather than swallowing this one. + void begin_batch(uint32_t now); + + // Feed observed traffic. Neither call gates forwarding: the detector only watches. + void from_device(uint8_t byte); + void from_host(uint8_t byte); + + bool armed() const { return this->state_ == ZWaveDetectState::ARMED; } + + /// True only while the host direction can still affect the state machine. Lets the + /// caller skip scanning that direction once armed -- it is the busier of the two. + bool needs_host_scan() const { return this->state_ != ZWaveDetectState::ARMED; } + + /// An acknowledgement became owed during the last from_device() call. Clears the flag. + bool take_pending_ack(); + + protected: + void handle_device_frame_(); + void reject_(); + + ZWaveFrameScanner device_scanner_; + ZWaveFrameScanner host_scanner_; + uint32_t device_frame_start_{0}; + uint32_t host_frame_start_{0}; + ZWaveDetectState state_{ZWaveDetectState::IDLE}; + uint8_t pending_command_{0}; // Command of the request awaiting its response + uint8_t unconfirmed_rejects_{0}; + bool ack_owed_{false}; +}; + +} // namespace esphome::zwave_proxy_tap + +#endif // USE_ZWAVE_PROXY_TAP diff --git a/esphome/components/zwave_proxy_tap/zwave_protocol.h b/esphome/components/zwave_proxy_tap/zwave_protocol.h new file mode 100644 index 0000000000..76a455e37b --- /dev/null +++ b/esphome/components/zwave_proxy_tap/zwave_protocol.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace esphome::zwave_proxy_tap { + +// Z-Wave Serial API framing (INS12350). Unlike ASH, a data frame is length-prefixed +// rather than delimited: +// +// SOF LEN TYPE CMD payload... CHK +// +// LEN counts every byte after itself, the checksum included, so a frame occupies LEN + 2 +// bytes on the wire. CHK is the XOR of LEN through the last payload byte, seeded with +// 0xFF. ACK, NAK and CAN stand alone as single bytes and carry no framing of their own. + +static constexpr uint8_t ZWAVE_SOF_BYTE = 0x01; // Start of a data frame +static constexpr uint8_t ZWAVE_ACK_BYTE = 0x06; // The only byte this component ever sends + +// TYPE field: which half of a request/response exchange the frame is +static constexpr uint8_t ZWAVE_FRAME_TYPE_REQUEST = 0x00; +static constexpr uint8_t ZWAVE_FRAME_TYPE_RESPONSE = 0x01; + +// Smallest LEN the protocol can produce: TYPE, CMD and CHK, with no payload +static constexpr uint8_t ZWAVE_MIN_LENGTH = 3; + +static constexpr uint8_t ZWAVE_CHECKSUM_INIT = 0xFF; + +// The specification requires a receiver to abandon a data frame that has not completed +// this long after its SOF byte. Nothing in the framing marks where a frame ends, so +// without this a truncated frame would swallow the start of the next one. +static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; + +} // namespace esphome::zwave_proxy_tap diff --git a/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp new file mode 100644 index 0000000000..d4b35c3d24 --- /dev/null +++ b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.cpp @@ -0,0 +1,62 @@ +#include "zwave_proxy_tap.h" + +#ifdef USE_ZWAVE_PROXY_TAP + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::zwave_proxy_tap { + +static const char *const TAG = "zwave_proxy_tap"; + +void ZWaveProxyTap::setup() { this->parent_->set_tap(this); } + +void ZWaveProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy Tap:\n Port: %s", this->parent_->get_name()); } + +void ZWaveProxyTap::on_device_rx(const uint8_t *data, size_t len) { + this->detector_.begin_batch(App.get_loop_component_start_time()); + for (size_t i = 0; i < len; i++) { + // Observation only: the detector never gates forwarding, so it adds no latency and a + // frame it cannot parse still reaches the client, which judges it for itself. + this->detector_.from_device(data[i]); + + if (this->detector_.take_pending_ack()) { + // The client suppresses its own ACKs, so this is the only acknowledgement the + // controller will see. Only ever sent for a frame that passed its checksum. + this->parent_->write_from_tap(&ZWAVE_ACK_BYTE, 1); + ESP_LOGV(TAG, "Sent ACK"); + } + } + + const bool armed = this->detector_.armed(); + if (armed != this->was_armed_) { + this->was_armed_ = armed; + ESP_LOGD(TAG, "Serial API session %s", + armed ? LOG_STR_LITERAL("detected, acknowledging frames") + : LOG_STR_LITERAL("lost, no longer acknowledging frames")); + } +} + +void ZWaveProxyTap::on_client_tx(const uint8_t *data, size_t len) { + // Scanning this direction only matters until an exchange completes. Once armed it is + // skipped entirely -- which is what makes a firmware upload, all of which flows this + // way, essentially free. + if (!this->detector_.needs_host_scan()) { + return; + } + this->detector_.begin_batch(App.get_loop_component_start_time()); + for (size_t i = 0; i < len; i++) { + this->detector_.from_host(data[i]); + } +} + +void ZWaveProxyTap::on_protocol_disabled() { + // A client turning protocol handling off is usually about to reflash the controller, so + // the exchange we saw says nothing about what will be on the wire next. Forget it: a + // real session proves itself again with another exchange. + this->detector_.reset(); +} + +} // namespace esphome::zwave_proxy_tap + +#endif // USE_ZWAVE_PROXY_TAP diff --git a/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h new file mode 100644 index 0000000000..16066b30a1 --- /dev/null +++ b/esphome/components/zwave_proxy_tap/zwave_proxy_tap.h @@ -0,0 +1,53 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ZWAVE_PROXY_TAP + +#include "esphome/components/serial_proxy/serial_proxy.h" +#include "esphome/core/component.h" +#include "zwave_detector.h" + +namespace esphome::zwave_proxy_tap { + +// Acknowledges the frames of a Z-Wave controller on behalf of a remote client, so the +// controller's ack timeout is measured against this device rather than against the +// network round trip to the client. The client suppresses its own acknowledgements, +// making these the only ones the controller sees. +// +// This is the serial_proxy counterpart of the zwave_proxy component. Where that one owns +// the UART, parses the Serial API in full and carries frames over its own API messages, +// this one only observes: the serial proxy owns the port and the bytes, and carries them +// like those of any other serial device. The sole exception is the acknowledgement +// itself, and it is sent only once a completed request/response exchange has proven the +// port really is carrying the Serial API. +class ZWaveProxyTap : public serial_proxy::SerialProxyTap, public Component { + public: + explicit ZWaveProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + void on_client_tx(const uint8_t *data, size_t len) override; + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } + void on_protocol_disabled() override; + + protected: + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_; + + // Decides when acknowledging on the client's behalf is safe. Armed only by a completed + // request/response exchange, so a bootloader or a firmware upload never triggers it. + ZWaveDetector detector_; + + // Previous armed state, for logging the transitions + bool was_armed_{false}; +}; + +} // namespace esphome::zwave_proxy_tap + +#endif // USE_ZWAVE_PROXY_TAP diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 452d983191..afeb0dd737 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -200,6 +200,7 @@ #define USE_WATER_HEATER_VISUAL_OVERRIDES #define USE_ZIGBEE_PROXY #define USE_ZWAVE_PROXY +#define USE_ZWAVE_PROXY_TAP // Feature flags which do not work for zephyr #ifndef USE_ZEPHYR diff --git a/tests/components/zwave_proxy_tap/.gitignore b/tests/components/zwave_proxy_tap/.gitignore new file mode 100644 index 0000000000..d8b4157aef --- /dev/null +++ b/tests/components/zwave_proxy_tap/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/tests/components/zwave_proxy_tap/common.yaml b/tests/components/zwave_proxy_tap/common.yaml new file mode 100644 index 0000000000..f3a5646372 --- /dev/null +++ b/tests/components/zwave_proxy_tap/common.yaml @@ -0,0 +1,16 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +# The port owns the UART and carries every byte; zwave_proxy_tap only taps it +serial_proxy: + - id: zwave_serial + uart_id: uart_bus + name: Z-Wave + port_type: TTL + +zwave_proxy_tap: + serial_proxy_id: zwave_serial diff --git a/tests/components/zwave_proxy_tap/test-usb.esp32-s3-idf.yaml b/tests/components/zwave_proxy_tap/test-usb.esp32-s3-idf.yaml new file mode 100644 index 0000000000..c5f2333c53 --- /dev/null +++ b/tests/components/zwave_proxy_tap/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,27 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +usb_host: + +# port_type is omitted deliberately: a port on a USB UART channel derives USB_SERIAL +usb_uart: + - type: CDC_ACM + vid: 0x0658 + pid: 0x0200 + channels: + - id: zwave_usb_channel + baud_rate: 115200 + +# The tapped port may be a USB CDC ACM channel just as well as a hardware UART: +# zwave_proxy_tap never touches the UART itself, so it does not care which it is. +serial_proxy: + - id: zwave_usb_serial + uart_id: zwave_usb_channel + name: Z-Wave + +zwave_proxy_tap: + serial_proxy_id: zwave_usb_serial diff --git a/tests/components/zwave_proxy_tap/test.esp32-idf.yaml b/tests/components/zwave_proxy_tap/test.esp32-idf.yaml new file mode 100644 index 0000000000..d9e13bcf93 --- /dev/null +++ b/tests/components/zwave_proxy_tap/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + zwave_proxy_tap: !include common.yaml diff --git a/tests/components/zwave_proxy_tap/test.esp8266-ard.yaml b/tests/components/zwave_proxy_tap/test.esp8266-ard.yaml new file mode 100644 index 0000000000..61525dd5bf --- /dev/null +++ b/tests/components/zwave_proxy_tap/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + zwave_proxy_tap: !include common.yaml diff --git a/tests/components/zwave_proxy_tap/test.rp2040-ard.yaml b/tests/components/zwave_proxy_tap/test.rp2040-ard.yaml new file mode 100644 index 0000000000..e035c866a9 --- /dev/null +++ b/tests/components/zwave_proxy_tap/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + zwave_proxy_tap: !include common.yaml