diff --git a/CODEOWNERS b/CODEOWNERS index 9bcbe087c5..253b0c05b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 +esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb57db9ce8..2d03052d63 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -23,6 +23,7 @@ #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING @@ -1849,8 +1850,7 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char bluetooth_mac[18]; + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac); #endif diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 954cb9fe87..d40f08d111 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -116,7 +116,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac, 6); + memcpy(report->mac, mac, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->evt_type = evt_type; @@ -230,7 +230,7 @@ void BK72xxBLE::loop() { ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); } -void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { +void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { for (int i = 0; i < 6; i++) out[i] = this->ble_mac_[i]; } @@ -263,7 +263,7 @@ void BK72xxBLE::resolve_mac_() { } } if (nonzero) { - memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); return; } #endif @@ -275,10 +275,10 @@ void BK72xxBLE::resolve_mac_() { // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment // would carry differently. - uint8_t wifi_mac[6]; + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(wifi_mac); // MSB-first - const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], - wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; // Store LSB-first to match recv_adv_t adv_addr ordering. for (int i = 0; i < 6; i++) this->ble_mac_[i] = ble[5 - i]; diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 7646f17161..687fd396e4 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -40,8 +40,8 @@ struct ScanParams { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the @@ -83,7 +83,7 @@ class BK72xxBLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } /// Controller BLE address, least-significant octet first (BLE convention). - void get_mac_lsb_first(uint8_t out[6]) const; + void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main task via loop()). @@ -135,13 +135,13 @@ class BK72xxBLE final : public Component { esphome::EventPool report_pool_; // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. uint32_t last_advance_ms_{0}; - uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change - uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none - uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS - int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none - ScanParams requested_{}; // latched by scan_start() - ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention) uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; bool scan_wanted_{false}; // the latched request is to scan (vs stopped) bool release_warned_{false}; // gates the release WARN; widens the pump gate diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 59d17f9b84..2334cfe414 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -116,8 +116,8 @@ class BK72xxBLETracker : public Component, bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index fc5bf5c1e0..23ca6b1dbd 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { // BLEScanResult's bda is most-significant octet first; the neutral ingest // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ // address_str_to() then produce exactly the historical esp32 values. - uint8_t mac_lsb_first[6]; + uint8_t mac_lsb_first[MAC_ADDRESS_SIZE]; for (uint8_t i = 0; i < 6; i++) mac_lsb_first[i] = scan_result.bda[5 - i]; this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index b5f198375c..668f7e09f8 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -241,7 +241,7 @@ class ESPBTDevice { // the 2-byte element header); every in-tree tracker scans legacy PDUs only. static constexpr uint8_t MAX_ADV_NAME_LEN = 29; - uint8_t address_[6]{0}; + uint8_t address_[MAC_ADDRESS_SIZE]{0}; uint8_t address_type_{0}; int rssi_{0}; // Fixed buffer instead of std::string: no per-advertisement heap churn on diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp index 2dd1fd6927..2c0d766683 100644 --- a/esphome/components/ble_device_base/scan_response_merger.cpp +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -2,6 +2,8 @@ #ifdef USE_BLE_SCAN_RESPONSE_MERGER +#include "esphome/core/helpers.h" + #include namespace esphome::ble_device_base { @@ -27,7 +29,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr free_slot = &p; continue; } - if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Same device advertised again before its scan response arrived — deliver // the previous advertisement (its scan response is not coming) and reuse // the slot, so no frame is ever lost. @@ -47,7 +49,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr } slot->used = true; this->pending_count_++; - memcpy(slot->mac, mac, 6); + memcpy(slot->mac, mac, MAC_ADDRESS_SIZE); slot->addr_type = addr_type; slot->rssi = rssi; slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); @@ -61,7 +63,7 @@ void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_ // hottest caller. if (this->pending_count_ != 0) { for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Append in place: the slot is released on delivery, so its 62-byte // buffer (legacy adv + scan response) holds the merged frame directly. const uint8_t room = sizeof(p.data) - p.data_len; diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h index 9415664fcf..f28790f207 100644 --- a/esphome/components/ble_device_base/scan_response_merger.h +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -120,7 +120,7 @@ class ScanResponseMerger { // as ESP-IDF delivers on ESP32. struct PendingAdv { bool used{false}; - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; uint8_t addr_type; int8_t rssi; uint8_t data_len; // <= sizeof(data) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 5052e7eca1..53e319e369 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -82,6 +82,16 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed +// Every sentinel must stay below the >= 0 streaming gate and clear of +// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused. +static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0); +static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED && + SERVICES_DONE_PENDING != GATT_NOT_CONNECTED); +// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done +// delivered near the client's 30 s timeout could land on a fresh request's +// empty accumulator and cache as an empty database. +static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; // ---- Service-streaming size budget, shared by every platform's streamer ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index f24d261c57..d6b815fc2e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -407,20 +407,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { return; } if (conn.send_service_ >= this->service_total_) { - conn.send_service_ = DONE_SENDING_SERVICES; - conn.proxy_->send_gatt_services_done(conn.address_); this->release_services(); + conn.send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list). + // The subscriber vanished mid-stream. auto *api_conn = conn.proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); - conn.send_service_ = DONE_SENDING_SERVICES; - this->release_services(); + conn.park_service_stream_(); return; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 08139bec6d..c43b2a6f7c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -21,7 +21,7 @@ void BluetoothConnection::set_address(uint64_t address) { this->address_str_[0] = '\0'; return; } - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); format_mac_addr_upper(mac, this->address_str_); } @@ -299,25 +299,39 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, // ---- Service streaming ---- +void BluetoothConnection::send_services_done_() { + if (this->proxy_->send_gatt_services_done(this->address_)) { + // Sent, or subscriber gone (park silently; its timeout arbitrates). + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (this->send_service_ != SERVICES_DONE_PENDING) { + // Warn on the transition only; retries stay silent. + ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); + this->services_done_retries_ = 0; + this->send_service_ = SERVICES_DONE_PENDING; + } else if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + } +} + void BluetoothConnection::send_service_for_discovery_() { auto table = this->backend_->get_service_table(); if (this->send_service_ >= table.service_count) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); this->backend_->release_services(); + this->send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list) and free the table; the - // api-gone sweep tears the connection down anyway. + // The subscriber vanished mid-stream; the api-gone sweep tears the + // connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, this->address_str_); - this->send_service_ = DONE_SENDING_SERVICES; - this->backend_->release_services(); + this->park_service_stream_(); return; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 396849a304..d964af5530 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -127,7 +127,23 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->send_service_for_discovery_(); } } + /// Park the stream without services-done and free any held table: an + /// interrupted stream must never be declared complete (the client's + /// timeout arbitrates), and an owed done is dropped with it. + void park_service_stream_() { + if (this->send_service_ >= 0) { + this->backend_->release_services(); + this->send_service_ = DONE_SENDING_SERVICES; + } else if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_service_ = DONE_SENDING_SERVICES; + } + } void send_service_for_discovery_(); + /// Send services-done and settle the cursor: DONE when it lands (or no + /// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain + /// retries). Callers release the table first; the message needs only the + /// address. + void send_services_done_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -152,10 +168,14 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); + // Ordered so neither byte's fields straddle a storage unit: 3+5 and + // 4+2+1+1 fill the two tail bytes exactly. ClientState state_ : 3 {ClientState::IDLE}; - bool paired_ : 1 {false}; - ConnectionType connection_type_ : 2 {ConnectionType::V1}; + static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); + uint8_t services_done_retries_ : 5 {0}; uint8_t connection_index_ : 4 {0}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + bool paired_ : 1 {false}; bool services_discovered_ : 1 {false}; }; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 08aa4689c2..dc8fb6714b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -5,6 +5,7 @@ #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -1258,7 +1259,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ } conn_err_t unpair_device(uint64_t address) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); bool found = false; BluetoothLock lock; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index af52a25ec0..13c84b86d1 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -40,7 +40,7 @@ static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { if (this->api_connection_ == nullptr) - return false; + return true; // Nobody subscribed: nothing owed api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -51,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK +void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) { + // False only on a refused frame, so the latch arms only when a retry is owed. + this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state); +} +#else void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -62,7 +67,7 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_BLE_SCANNER_STATE_CALLBACK +#endif // USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -78,7 +83,7 @@ void BluetoothProxy::setup() { #ifdef USE_BLE_SCANNER_STATE_CALLBACK // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { - static_cast(self)->send_bluetooth_scanner_state_(state); + static_cast(self)->send_scanner_state_(state); }}); #endif } @@ -135,7 +140,7 @@ void BluetoothProxy::dump_config() { // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. - char mac_str[18]; + char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; @@ -190,8 +195,48 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); } +void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { + // Match before free entry so one address never occupies two pool slots. + PendingDisconnect *free_entry = nullptr; + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.matches(address)) { + owed.set(address, error); + return; + } + if (free_entry == nullptr && owed.empty()) { + free_entry = &owed; + } + } + if (free_entry != nullptr) { + free_entry->set(address, error); + return; + } + // Every entry is owed: evict the first so the newest loss is not silent too. + ESP_LOGW(TAG, "Owed disconnect dropped (0x%llx), retry pool full", + (unsigned long long) this->pending_disconnections_[0].address()); + this->pending_disconnections_[0].set(address, error); +} + +void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { + // A reconnect supersedes the owed disconnect; a late resend would shadow + // the new connection. + for (uint8_t i = 0; i < this->connection_count_; i++) { + if (this->pending_disconnections_[i].matches(address)) { + this->pending_disconnections_[i].clear(); + } + } +} + void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { - this->send_device_connection(connection->get_address(), false, 0, reason); + if (!this->send_device_connection(connection->get_address(), false, 0, reason)) { + // The client has no other way to learn of an unsolicited disconnect; + // latch and let loop()'s paced drain deliver it. V by design: a louder + // level would ride the same congested link this reports on. + ESP_LOGV(TAG, "[%d] [%s] Disconnect notification deferred, TCP buffer full", connection->get_connection_index(), + connection->address_str()); + this->latch_pending_disconnection_(connection->get_address(), reason); + } connection->set_address(0); connection->send_service_ = INIT_SENDING_SERVICES; this->send_connections_free(); @@ -206,14 +251,20 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); - if (conn_addr == address) + if (conn_addr == address) { + // A connect request supersedes an owed disconnect. + if (reserve) { + this->clear_pending_disconnection_(address); + } return connection; + } if (free_slot == nullptr && conn_addr == 0) free_slot = connection; } if (!reserve || free_slot == nullptr) return nullptr; + this->clear_pending_disconnection_(address); free_slot->send_service_ = INIT_SENDING_SERVICES; free_slot->set_address(address); // All connections must start at INIT @@ -387,7 +438,30 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer } if (!connection->has_gatt_services()) { ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); - this->send_gatt_services_done(msg.address); + // Through the retrying sender: a drop must not leave discovery hanging. + // Re-entry does not depend on the cursor - this branch is gated on + // has_gatt_services() alone, so no restore is needed. + connection->send_services_done_(); + return; + } + if (connection->send_service_ > 0) { + // A request mid-stream restarts from the top so the requester always + // gets the full list. No duplicate risk: the client accumulates batches + // per request, and a same-session re-request only happens after the + // previous request timed out and discarded its partial list. + ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(), + connection->address_str()); + connection->send_service_ = 0; + return; + } + if (connection->send_service_ == SERVICES_DONE_PENDING) { + // A new request supersedes an owed done: the client accumulates batches + // per request, so its fresh, empty accumulator plus a bare done would + // cache as an empty database. The table is freed; the client's timeout + // arbitrates. + ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry", + connection->get_connection_index(), connection->address_str()); + connection->send_service_ = DONE_SENDING_SERVICES; return; } if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet @@ -515,7 +589,27 @@ void BluetoothProxy::loop() { return; } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Paced retries of owed per-slot notifications; subscriber swaps clear + // stale latches before this runs. + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->send_service_ == SERVICES_DONE_PENDING) { + connection->send_services_done_(); + } + auto &owed = this->pending_disconnections_[i]; + if (!owed.empty() && this->send_device_connection(owed.address(), false, 0, owed.error())) { + owed.clear(); + } + } +#endif + +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Resend a dropped scanner-state push (see scanner_state_pending_). + if (this->scanner_state_pending_) { + this->send_scanner_state_(this->hub_->get_scanner_state()); + } +#else // This hub doesn't push scanner-state transitions; poll and report on // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -601,24 +695,35 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - // A previous subscriber still holds the slot. This is almost always a stale - // connection from a client that dropped without a clean disconnect and has - // not yet hit the keepalive timeout; rejecting the new subscriber would - // silently starve it of advertisements until it reconnects, so the newest - // subscriber wins instead. - char old_peername[socket::SOCKADDR_STR_LEN]; - char new_peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), - api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), - this->api_connection_->get_peername_to(old_peername)); + if (api_connection != this->api_connection_) { + if (this->api_connection_ != nullptr) { + // A previous subscriber still holds the slot. This is almost always a + // stale connection from a client that dropped without a clean disconnect + // and has not yet hit the keepalive timeout; rejecting the new + // subscriber would silently starve it of advertisements until it + // reconnects, so the newest subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); + } + // Stale retry latches belong to the previous subscriber's session; a + // re-subscribe by the current one keeps what it is still owed. + this->connections_free_pending_ = false; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the new + // session; silence (the client's timeout) arbitrates. + this->connections_[i]->park_service_stream_(); + } + this->pending_disconnections_.fill({}); +#endif } - // A stale retry latch belongs to the previous subscriber's session. - this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). - this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); + this->send_scanner_state_(this->hub_->get_scanner_state()); #else this->send_polled_scanner_state_(); #endif @@ -631,20 +736,11 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti } this->api_connection_ = nullptr; this->connections_free_pending_ = false; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + this->scanner_state_pending_ = false; +#endif } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { - if (this->api_connection_ == nullptr) - return; - api::BluetoothDeviceConnectionResponse call; - call.address = address; - call.connected = connected; - call.mtu = mtu; - call.error = error; - // Fire and forget: a drop is covered by the client's own timeouts and the - // retried connections-free state. - this->api_connection_->send_message(call); -} void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -661,12 +757,23 @@ void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { } } -void BluetoothProxy::send_gatt_services_done(uint64_t address) { +bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing owed + api::BluetoothDeviceConnectionResponse call; + call.address = address; + call.connected = connected; + call.mtu = mtu; + call.error = error; + return this->api_connection_->send_message(call); +} + +bool BluetoothProxy::send_gatt_services_done(uint64_t address) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTGetServicesDoneResponse call; call.address = address; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d3e3144831..cf7a09a7e5 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -10,6 +10,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/bluetooth_connection/bluetooth_connection.h" @@ -24,7 +25,9 @@ namespace esphome::bluetooth_proxy { using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES; +using bluetooth_connection::SERVICES_DONE_PENDING; #ifdef BLUETOOTH_CONNECTION_HAS_GATT using BluetoothConnection = bluetooth_connection::BluetoothConnection; @@ -57,6 +60,43 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +/// One owed freed-slot connected=false notification in a single word: the +/// 48-bit address in the low bits, the sign-extending 16-bit reason on top. +/// Every reason that reaches the pool (esp_gatt_status_t, +/// esp_gatt_conn_reason_t, generic ESP_ERR_*, -1) fits int16_t. +class PendingDisconnect { + public: + constexpr void set(uint64_t address, conn_err_t error) { + // Mask: the address originates from the client, and a stray high bit + // must not corrupt the reason. + this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); + } + constexpr void clear() { this->word_ = 0; } + // Whole-word test: set() is only ever given a live (nonzero) address. + constexpr bool empty() const { return this->word_ == 0; } + // Masked like set(), so a stray high bit cannot defeat the pool lookups. + constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } + constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; } + constexpr conn_err_t error() const { return static_cast(this->word_ >> 48); } + + private: + static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL; + uint64_t word_{0}; +}; +// Pin the packing at compile time: mask and sign round-trip for every +// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). +constexpr bool pending_disconnect_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingDisconnect p; + p.set(address, error); + return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); +} +static_assert(pending_disconnect_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_disconnect_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_disconnect_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingDisconnect{}.empty()); +#endif + class BluetoothProxy final : public Component { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Allow the connection to update connections_free_response_ @@ -97,10 +137,14 @@ class BluetoothProxy final : public Component { return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); + /// False only when a subscriber refused the frame; true = delivered or + /// nobody subscribed. Request-answer callers ignore the result (client + /// timeouts cover those); only reset_connection_slot_ latches for retry. + bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); - void send_gatt_services_done(uint64_t address); + /// Same convention as send_device_connection: false only on a refused frame. + bool send_gatt_services_done(uint64_t address); void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); @@ -158,8 +202,8 @@ class BluetoothProxy final : public Component { return flags; } - void get_bluetooth_mac_address_pretty(std::span output) { - uint8_t mac[6] = {}; + void get_bluetooth_mac_address_pretty(std::span output) { + uint8_t mac[MAC_ADDRESS_SIZE] = {}; this->hub_->get_adapter_mac(mac); // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn // the address once the link layer is up, and report all-zero until then. @@ -172,7 +216,9 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void send_scanner_state_(ble_device_base::ScannerState state); +#else void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -231,6 +277,10 @@ class BluetoothProxy final : public Component { /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service /// discovery and retry, rather than being told a partial list is complete. void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); + /// Drop any owed freed-slot notification for this address (client reconnected). + void clear_pending_disconnection_(uint64_t address); + /// Pool a refused freed-slot notification for the paced drain. + void latch_pending_disconnection_(uint64_t address, conn_err_t error); #endif // Memory optimized layout for 32-bit systems @@ -240,6 +290,10 @@ class BluetoothProxy final : public Component { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Group 2: Fixed-size array of connection pointers std::array connections_{}; + // Address-keyed pool of owed freed-slot notifications; loop() resends. + // Proxy-only state, kept off BluetoothConnection; entries are not tied to + // slot indices. + std::array pending_disconnections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below @@ -260,7 +314,11 @@ class BluetoothProxy final : public Component { bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // A dropped push (full TX buffer) is re-queried from the hub and resent + // from loop(); the hub's current state is idempotent by construction. + bool scanner_state_pending_{false}; +#else bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d11683ab35..16501ef3b2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif -void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { +void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { // The running stack owns the address (on hosted controllers it lives in // the remote chip's efuse); null before init becomes all-zero. const uint8_t *mac = esp_bt_dev_get_address(); if (mac != nullptr) { - memcpy(out, mac, 6); + memcpy(out, mac, MAC_ADDRESS_SIZE); } else { - memset(out, 0, 6); + memset(out, 0, MAC_ADDRESS_SIZE); } } float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - uint8_t mac_address[6]; + uint8_t mac_address[MAC_ADDRESS_SIZE]; this->get_mac_msb_first(mac_address); if (mac_address_is_valid(mac_address)) { const char *io_capability_s; @@ -713,7 +713,7 @@ void ESP32BLE::dump_config() { break; } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 45cfd8ee71..2a355a6c8b 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -109,7 +109,7 @@ class ESP32BLE final : public Component { void loop() override; void dump_config() override; /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 30b85b5417..7c3e5538fd 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_active() { return this->scan_active_; } // The mode is driven through this tracker's own API (see get_capabilities); diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@zweckj"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True + +CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" + +hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp") +HoermannHcp = hoermann_hcp_ns.class_( + "HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice +) + +# The Hoermann UAP module answers on Modbus server address 2. +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)}) + .extend(cv.polling_component_schema("500ms")) + .extend(modbus.modbus_device_schema(0x02, role="server")) +) + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device( + "hoermann_hcp", role="server" +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component) + +CONFIG_SCHEMA = ( + cover.cover_schema(HoermannHcpCover) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await cover.new_cover(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -0,0 +1,87 @@ +#include "hoermann_hcp_cover.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.cover"; + +cover::CoverTraits HoermannHcpCover::get_traits() { + cover::CoverTraits traits; + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void HoermannHcpCover::setup() { + // Nothing is published before the bus controller is heard from, and the untouched position reads as fully + // open, so flag the entity until the first contact clears it again. + this->status_set_warning("waiting for the bus controller"); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); } + +void HoermannHcpCover::control(const cover::CoverCall &call) { + bool accepted = true; + if (call.get_stop()) + accepted &= this->parent_->stop_door(); + if (call.get_toggle().has_value()) + accepted &= this->parent_->impulse_door(); + if (const auto position = call.get_position()) + accepted &= this->parent_->set_position(*position); + if (!accepted) { + // The command never reached the door, so publish the unchanged state over the one the caller assumed. + ESP_LOGW(TAG, "Command was not accepted by the door"); + this->publish_state(false); + } +} + +void HoermannHcpCover::update_from_state_() { + if (!this->parent_->is_valid()) { + this->status_set_warning(); + // The door can now move unheard, so drop the baseline a direction would be inferred from and stop + // reporting motion instead of leaving the cover travelling until the controller returns. + this->previous_position_ = NAN; + if (this->current_operation != cover::COVER_OPERATION_IDLE) { + this->current_operation = cover::COVER_OPERATION_IDLE; + this->publish_state(); + } + return; + } + this->status_clear_warning(); + + const auto previous_operation = this->current_operation; + const float current_position = this->parent_->get_current_position(); + switch (this->parent_->get_door_state()) { + case DoorState::OPENING: + this->current_operation = cover::COVER_OPERATION_OPENING; + break; + case DoorState::CLOSING: + this->current_operation = cover::COVER_OPERATION_CLOSING; + break; + case DoorState::MOVE_VENTING: + case DoorState::MOVE_HALF: + // These states carry no direction, so keep the current one until the position actually moves. + if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) { + this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING + : cover::COVER_OPERATION_CLOSING; + } + break; + default: + this->current_operation = cover::COVER_OPERATION_IDLE; + break; + } + this->previous_position_ = current_position; + + // Compare against the position last published, which starts at COVER_OPEN rather than at zero. + const bool changed = this->position != current_position || previous_operation != this->current_operation; + this->position = current_position; + if (changed) { + // The bus reports the position on every broadcast, so nothing here is worth restoring from flash. + this->publish_state(false); + } +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpCover : public cover::Cover, public Component { + public: + explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + cover::CoverTraits get_traits() override; + void control(const cover::CoverCall &call) override; + + protected: + void update_from_state_(); + HoermannHcp *const parent_; + // NAN until the first position is observed, so no direction is inferred from a baseline that never existed. + float previous_position_{NAN}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..0dc146a061 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,336 @@ +#include "hoermann_hcp.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp"; + +// Hoermann HCP holding-register blocks. +static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller +static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller +static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller +static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; +static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; + +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; + +// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because +// its low byte tells a plain stop from the vent position. +struct DoorStateMapping { + uint8_t code; + DoorState state; +}; +static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = { + {0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF}, + {0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN}, + {0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN}, +}; + +// The hub rejects a reply whose register count does not match the request, so an unrecognized block length +// is padded with zeros rather than answered with an exception that would fail the controller's whole poll. +static void push_zeros(modbus::RegisterValues ®isters, uint16_t count) { + for (uint16_t i = 0; i < count; i++) + registers.push_back(0x0000); +} + +// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving. +static bool is_moving(DoorState state) { + switch (state) { + case DoorState::OPENING: + case DoorState::CLOSING: + case DoorState::MOVE_HALF: + case DoorState::MOVE_VENTING: + return true; + default: + return false; + } +} + +void HoermannHcp::update() { + const uint32_t now = millis(); + // Time out the connection flag if the bus controller stopped polling. + if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_) + this->set_valid_(false); + // Status broadcasts alone keep the connection alive, so a command the controller never fetches would + // otherwise block every later one for as long as it keeps broadcasting. + if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + this->next_command_ = nullptr; + this->command_written_at_ = 0; + this->clear_target_(); + } + // A target waits for a door still travelling the other way to turn around. If it never does, the target has + // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. + if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); + this->clear_target_(); + } + if (this->changed_) { + this->changed_ = false; + this->state_callback_.call(); + } +} + +void HoermannHcp::dump_config() { + ESP_LOGCONFIG(TAG, + "Hoermann HCP bridge:\n" + " Modbus server address: 0x%02X", + this->get_address()); +} + +modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + if (start_address != STATE_REG) { + ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message + // counter (high byte) and command (low byte). The read length identifies which internal block is requested. + const uint16_t counter = this->command_reg_value_ & 0xFF00; + const uint16_t command = static_cast((this->command_reg_value_ & 0x00FF) << 8); + + switch (number_of_registers) { + case 8: + // Command request: return the internal state, injecting any pending command. + registers.push_back(counter); + registers.push_back(static_cast(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(0x0004 | counter)); + registers.push_back(command); + break; + case 5: + // Bus scan (the bus controller discovering us, typically at startup). + ESP_LOGD(TAG, "Bus scan received from bus controller"); + registers.push_back(counter); + registers.push_back(static_cast(0x0005 | command)); + registers.push_back(0x0430); + registers.push_back(0x10FF); + registers.push_back(0xA845); + break; + default: + ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers); + push_zeros(registers, number_of_registers); + break; + } + + return {}; +} + +modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + if (start_address == COMMAND_REG) { + // 0x17 write half: stash the command register so the following read half can echo its message counter and + // command byte back from STATE_REG. The hub always runs the write before the read within one request. + this->record_response_(); + this->command_reg_value_ = registers[0]; + return {}; + } + + if (start_address != BROADCAST_REG) { + // Every device sees every broadcast, so a frame meant for another node is ordinary traffic + ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new + // position checks the target against the new state. + if (registers.size() > 2) + this->on_state_reg_(registers[2]); + if (registers.size() > 1) + this->on_position_reg_(registers[1]); + return {}; +} + +void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) { + push_zeros(registers, 2); + return; + } + if (this->command_written_at_ == 0) { + // First read after the command was queued: present the "key pressed" values. + this->command_written_at_ = millis(); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->pressed_value); + registers.push_back(0x0000); + return; + } + if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { + // Still inside the key-press window, so keep presenting 0x0000. + push_zeros(registers, 2); + return; + } + // Enough time passed: present the "key released" values and clear the command. + ESP_LOGD(TAG, "Released '%s' command", command->name); + this->command_written_at_ = 0; + this->next_command_ = nullptr; + registers.push_back(command->released_value); + registers.push_back(0x0000); +} + +void HoermannHcp::on_position_reg_(uint16_t value) { + // Low byte: current position. + const uint8_t position = static_cast(value); + if (this->position_raw_ == position) + return; + + this->position_raw_ = position; + this->update_current_position_(); + // Until the door actually travels the way it was told to, its position says nothing about the target. + if (!this->has_target_() || !this->target_started_) + return; + + // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way. + const bool reached = this->target_direction_ == DoorState::OPENING + ? this->current_position_ >= this->target_position_ + : this->current_position_ <= this->target_position_; + if (reached) + this->stop_door(); +} + +void HoermannHcp::on_state_reg_(uint16_t value) { + // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte. + const uint16_t previous = this->prev_state_reg_; + this->prev_state_reg_ = value; + if (previous == value) + return; + + const uint8_t state = value >> 8; + if (state == 0x00) { + // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop. + this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED); + return; + } + for (const auto &mapping : DOOR_STATE_MAPPINGS) { + if (mapping.code == state) { + this->set_door_state_(mapping.state); + return; + } + } + // The low byte can change on its own, so only report a state we cannot decode once. + if (state != (previous >> 8)) + ESP_LOGW(TAG, "Unknown door state 0x%02X", state); +} + +bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { + if (!this->valid_) { + // Queueing now would fire the command whenever the controller comes back, which may be much later. + ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); + return false; + } + if (this->next_command_ != nullptr) { + ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); + return false; + } + // A new command supersedes any half-open target the door was still travelling to. + this->clear_target_(); + this->next_command_ = &command; + this->command_queued_at_ = millis(); + return true; +} + +bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } + +bool HoermannHcp::stop_door() { + if (!is_moving(this->door_state_)) { + this->clear_target_(); + return true; + } + // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. + return this->queue_command_(COMMAND_IMPULSE); +} + +bool HoermannHcp::set_position(float position) { + // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed. + if (position <= CLOSE_POSITION_THRESHOLD) + return this->close_door(); + if (position >= OPEN_POSITION_THRESHOLD) + return this->open_door(); + // Asking the door to travel to where it already is means stopping it. + if (position == this->current_position_) + return this->stop_door(); + + // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. + const bool opening = position > this->current_position_; + if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) + return false; + this->target_position_ = position; + this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; + // A door already travelling that way is on its way; one moving the other way has to turn around first. + this->target_started_ = this->door_state_ == this->target_direction_; + return true; +} + +void HoermannHcp::record_response_() { + this->last_response_ = millis(); + this->set_valid_(true); +} + +void HoermannHcp::set_valid_(bool valid) { + if (this->valid_ == valid) + return; + this->valid_ = valid; + this->changed_ = true; + if (valid) { + ESP_LOGI(TAG, "Bus controller connected"); + return; + } + ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); + // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->next_command_ = nullptr; + this->command_written_at_ = 0; + this->clear_target_(); +} + +void HoermannHcp::set_door_state_(DoorState state) { + if (this->door_state_ == state) + return; + this->door_state_ = state; + this->changed_ = true; + this->update_current_position_(); + if (!this->has_target_()) + return; + if (state == this->target_direction_) { + this->target_started_ = true; + } else if (this->target_started_ && !is_moving(state)) { + // The door came to rest without reaching the target, so the request it belonged to is over. + this->clear_target_(); + } +} + +void HoermannHcp::update_current_position_() { + // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so + // trust the reported end stop over the raw count. + float position = static_cast(this->position_raw_) / 200.0f; + if (this->door_state_ == DoorState::CLOSED) { + position = 0.0f; + } else if (this->door_state_ == DoorState::OPEN) { + position = 1.0f; + } + if (this->current_position_ != position) { + this->current_position_ = position; + this->changed_ = true; + } +} + +void HoermannHcp::clear_target_() { + this->target_position_ = 0.0f; + this->target_started_ = false; +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..142365f16e --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,110 @@ +#pragma once + +#include + +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::hoermann_hcp { + +// Door state as reported by the Hoermann bus controller. +enum class DoorState : uint8_t { + OPEN, + OPENING, + CLOSED, + CLOSING, + HALF_OPEN, + MOVE_VENTING, + VENT, + MOVE_HALF, + STOPPED, +}; + +// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a +// short delay the released value. The second command register remains zero. +struct HoermannHcpCommand { + const char *name; + uint16_t pressed_value; + uint16_t released_value; +}; + +class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { + public: + void update() override; + void dump_config() override; + + // Registered by child entities to be notified when the door state changes. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + + // Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write + // half first, storing the command register that the read half echoes back) and broadcasts status with 0x10. + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) override; + modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) override; + + // Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus + // controller cannot be asked right now, so the caller can react. + bool open_door(); + bool close_door(); + bool impulse_door(); + bool stop_door(); + bool set_position(float position); + + DoorState get_door_state() const { return this->door_state_; } + float get_current_position() const { return this->current_position_; } + bool is_valid() const { return this->valid_; } + + protected: + void record_response_(); + // Returns false when the bus controller has not fetched the previous command yet. + bool queue_command_(const HoermannHcpCommand &command); + // Appends the two key-press registers and advances the pending command's press/release state. + void push_command_registers_(modbus::RegisterValues ®isters); + void on_position_reg_(uint16_t value); + void on_state_reg_(uint16_t value); + + void set_valid_(bool valid); + void set_door_state_(DoorState state); + // Recomputes the reported position from position_raw_ and the current door state. + void update_current_position_(); + bool has_target_() const { return this->target_position_ != 0.0f; } + void clear_target_(); + + CallbackManager state_callback_; + + float current_position_{0.0f}; + // Position the door was told to travel to; 0.0 means no target is armed. + float target_position_{0.0f}; + + // Pending command / key-press state machine. + const HoermannHcpCommand *next_command_{nullptr}; + uint32_t command_queued_at_{0}; + uint32_t command_written_at_{0}; + uint32_t last_response_{0}; + + // A command is "pressed" for this long before its end value is sent. + uint16_t key_press_delay_ms_{100}; + // Drop the "connected" flag if the bus controller has not polled us for this long. + uint16_t connection_timeout_ms_{2000}; + // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when + // it reads 0x0000. + uint16_t prev_state_reg_{0xFFFF}; + // 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message + // counter and low-byte command back from STATE_REG. + uint16_t command_reg_value_{0}; + + DoorState door_state_{DoorState::CLOSED}; + // Direction the door was started in for the current target. A target armed while the door is still travelling + // the other way must not be judged by the reported direction until the door has turned around. + DoorState target_direction_{DoorState::STOPPED}; + // Position as reported by the bus controller, 0..200 across the full travel. + uint8_t position_raw_{0}; + bool target_started_{false}; + bool valid_{false}; + bool changed_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f..914de8e145 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -8,6 +8,7 @@ #endif #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::ld2410 { @@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index a0cce36d16..061846f1f1 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; #ifdef USE_NUMBER diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dc..7041b7539f 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index f722f938ae..a52402c2ea 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 0dc2638aad..4b41d63a88 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 10f9bb874a..c4f06ad224 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t moving_presence_millis_ = 0; uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t zone_type_ = 0; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index cba1b68a15..deac04e86f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; // Helper function to format MAC address with stack allocation // Returns pointer to UNKNOWN_MAC constant or formatted buffer -// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) -inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { if (mac_address_is_valid(mac_address)) { format_mac_addr_upper(mac_address, buffer.data()); return buffer.data(); diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 152ca571e9..021e138f08 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) { // downstream the value is used exactly like on ESP32. const int8_t raw = info->rssi; - memcpy(slot->mac, info->trans_addr, 6); + memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE); slot->rssi = (raw > 20) ? static_cast(-raw) : raw; slot->addr_type = info->trans_addr_type; slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; @@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); } if (!have_unique_addr) { - uint8_t wifi_mac[6] = {0}; + uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0}; get_mac_address_raw(wifi_mac); // MSB-first // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the @@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); } } - memcpy(this->ble_mac_, bt_addr.addr, 6); + memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE); } // --------------------------------------------------------------------------- diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index 2186822208..5b4a67b566 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t { /// One scan report from the controller, decoded from the SDK's rw-task event /// (RSSI already sign-corrected). struct BLEScanReport { - uint8_t mac[6]; // as the controller delivers it (LSB-first) - int8_t rssi; // signed dBm (-127..+20) + uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) uint8_t addr_type; bool is_scan_response; // report is a scan response (active scan) bool scannable; // advertisement may be followed by a scan response @@ -138,7 +138,7 @@ class LN882HBLE final : public Component { // Reports rejected by the legacy-only filter (rw-task producer, main-task // consumer via exchange in loop()). std::atomic rejected_reports_{0}; - uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; bool scanning_{false}; // controller scan running (re-entry guard for scan_start) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 2d88b938dd..dc42aebce9 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -90,8 +90,8 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 415de34166..6683a9a25b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -4,6 +4,7 @@ #include #include #include +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -11,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver"; static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; - static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; static constexpr uint8_t PREAMBLE = 0xFC; @@ -31,86 +30,6 @@ static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -template struct LookupMap { - using value_type = decltype(Unknown); - static constexpr auto UNKNOWN_VALUE = Unknown; - const std::array table; - - constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } - - constexpr bool reverse_lookup(value_type value, uint8_t &out) const { - static_assert(N <= std::numeric_limits::max()); - if (value == UNKNOWN_VALUE) { - return false; - } - for (uint8_t i = 0; i < static_cast(N); ++i) { - if (this->table[i] == value) { - out = i; - return true; - } - } - return false; - } - - constexpr bool is_valid(value_type value) const { - uint8_t raw; - return reverse_lookup(value, raw); - } -}; - -template static constexpr auto make_map(const T (&values)[N]) { - return LookupMap{std::to_array(values)}; -} - -static constexpr auto PROTOCOL_MODE_MAP = make_map({ - MitsubishiCN105::Mode::UNKNOWN, // 0x00 - MitsubishiCN105::Mode::HEAT, // 0x01 - MitsubishiCN105::Mode::DRY, // 0x02 - MitsubishiCN105::Mode::COOL, // 0x03 - MitsubishiCN105::Mode::UNKNOWN, // 0x04 - MitsubishiCN105::Mode::UNKNOWN, // 0x05 - MitsubishiCN105::Mode::UNKNOWN, // 0x06 - MitsubishiCN105::Mode::FAN_ONLY, // 0x07 - MitsubishiCN105::Mode::AUTO // 0x08 -}); - -static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ - MitsubishiCN105::FanMode::AUTO, // 0x00 - MitsubishiCN105::FanMode::QUIET, // 0x01 - MitsubishiCN105::FanMode::SPEED_1, // 0x02 - MitsubishiCN105::FanMode::SPEED_2, // 0x03 - MitsubishiCN105::FanMode::UNKNOWN, // 0x04 - MitsubishiCN105::FanMode::SPEED_3, // 0x05 - MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}); - -static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map({ - MitsubishiCN105::VaneMode::AUTO, // 0x00 - MitsubishiCN105::VaneMode::POSITION_1, // 0x01 - MitsubishiCN105::VaneMode::POSITION_2, // 0x02 - MitsubishiCN105::VaneMode::POSITION_3, // 0x03 - MitsubishiCN105::VaneMode::POSITION_4, // 0x04 - MitsubishiCN105::VaneMode::POSITION_5, // 0x05 - MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::VaneMode::SWING // 0x07 -}); - -static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map({ - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 - MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 - MitsubishiCN105::WideVaneMode::LEFT, // 0x02 - MitsubishiCN105::WideVaneMode::CENTER, // 0x03 - MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 - MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 - MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B - MitsubishiCN105::WideVaneMode::SWING // 0x0C -}); - static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -124,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } @@ -277,14 +192,14 @@ bool MitsubishiCN105::should_request_telemetry_() const { return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } -void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - FrameParser::dump_buffer_vv("TX", packet, len); - this->device_.write_array(packet, len); +void MitsubishiCN105::send_packet_(std::span packet) { + FrameParser::dump_buffer_vv("TX", packet.data(), packet.size()); + this->device_.write_array(packet.data(), packet.size()); this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { - std::array payload = {this->current_status_msg_type_}; + std::array payload{this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } @@ -336,12 +251,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: - return this->parse_status_settings_(payload, len); + if (!decoder.decode_settings(this->status_)) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + return true; case STATUS_MSG_TELEMETRY: - return this->parse_status_telemetry_(payload, len); + if (!decoder.decode_room_temperature(this->status_)) { + ESP_LOGVV(TAG, "RX telemetry payload too short"); + return false; + } + this->last_telemetry_update_ms_ = get_loop_time_ms(); + return true; default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -349,54 +274,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay } } -bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { - if (len <= 10) { - ESP_LOGVV(TAG, "RX settings payload too short"); - return false; - } - - if (!this->pending_updates_.contains(UpdateFlag::POWER)) { - this->status_.power_on = payload[2] != 0; - } - - this->use_temperature_encoding_b_ = payload[10] != 0; - if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); - } - - if (!this->pending_updates_.contains(UpdateFlag::MODE)) { - const bool i_see = payload[3] > 0x08; - this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); - } - - if (!this->pending_updates_.contains(UpdateFlag::VANE)) { - this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); - } - - this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; - if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { - this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); - } - - return true; -} - -bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) { - if (len <= 5) { - ESP_LOGVV(TAG, "RX telemetry payload too short"); - return false; - } - - this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_telemetry_update_ms_ = get_loop_time_ms(); - - return true; -} - void MitsubishiCN105::set_remote_temperature(float temperature) { if (std::isnan(temperature)) { ESP_LOGD(TAG, "Ignoring NaN remote temperature"); @@ -415,12 +292,12 @@ void MitsubishiCN105::clear_remote_temperature() { void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) { this->remote_temperature_half_deg_ = temperature_half_deg; - this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Remote::ID); } void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; - this->pending_updates_.set(UpdateFlag::POWER); + this->pending_updates_.set(Property::Power::ID); } void MitsubishiCN105::set_target_temperature(float target_temperature) { @@ -429,101 +306,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { return; } this->status_.target_temperature = target_temperature; - this->pending_updates_.set(UpdateFlag::TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Target::ID); } void MitsubishiCN105::set_mode(Mode mode) { - if (!PROTOCOL_MODE_MAP.is_valid(mode)) { - ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); - return; + if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } - this->status_.mode = mode; - this->pending_updates_.set(UpdateFlag::MODE); } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { - ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); - return; + if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast(fan_mode)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { - if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { - ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); - return; + if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast(vane_mode)); } - this->status_.vane_mode = vane_mode; - this->pending_updates_.set(UpdateFlag::VANE); } void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { - if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { - ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); - return; + if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast(wide_vane_mode)); } - this->status_.wide_vane_mode = wide_vane_mode; - this->pending_updates_.set(UpdateFlag::WIDE_VANE); } void MitsubishiCN105::apply_settings_() { std::array payload{}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; // Apply all other pending settings first; handle REMOTE_TEMPERATURE last - if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) { - payload[0] = 0x07; - if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) { - payload[3] = 0x80; - } else { - payload[1] = 0x01; - payload[2] = static_cast(this->remote_temperature_half_deg_ - 16); - payload[3] = static_cast(this->remote_temperature_half_deg_ + 128); - } - this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE); + if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { + encoder.encode_remote_temperature(this->remote_temperature_half_deg_); } else { - payload[0] = 0x01; - if (this->pending_updates_.contains(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); - } else { - payload[5] = - static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); - } - } - - if (this->pending_updates_.contains(UpdateFlag::MODE) && - PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - if (this->pending_updates_.contains(UpdateFlag::VANE) && - PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { - payload[1] |= 0x10; - } - - if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && - PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { - payload[2] |= 0x01; - if (this->set_wide_vane_high_bit_) { - payload[13] |= 0x80; - } - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, - UpdateFlag::VANE, UpdateFlag::WIDE_VANE); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 3169359290..b6b11b4820 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -5,6 +5,7 @@ #include #include +#include namespace esphome::mitsubishi_cn105 { @@ -121,44 +122,47 @@ class MitsubishiCN105 { uint8_t read_pos_{0}; }; - enum class UpdateFlag : uint8_t { + enum class PropertyId : uint8_t { TEMPERATURE = 0, POWER = 1, MODE = 2, FAN = 3, VANE = 4, WIDE_VANE = 5, - REMOTE_TEMPERATURE = 6, + REMOTE_TEMPERATURE = 6 }; struct UpdateFlags { - template void set(Flags... flags) { (this->mask_.insert(flags), ...); } - template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + void set(PropertyId id) { this->mask_.insert(id); } + void clear(PropertyId id) { this->mask_.erase(id); } bool any() const { return !this->mask_.empty(); } - bool contains(UpdateFlag flag) const { return this->mask_.count(flag); } - bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); } + bool contains(PropertyId id) const { return this->mask_.count(id); } + bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); } protected: using Mask = - FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; - + FiniteSetMask(PropertyId::REMOTE_TEMPERATURE) + 1>>; Mask mask_; }; + struct PropertyContext { + bool use_temperature_encoding_b{false}; + bool set_wide_vane_high_bit{false}; + }; + + friend struct Property; + void set_state_(State new_state); void did_transition_(State to); bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); - bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_telemetry_(const uint8_t *payload, size_t len); - void send_packet_(const uint8_t *packet, size_t len); + void send_packet_(std::span packet); void update_status_(); bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); - template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } static bool should_transition(State from, State to); static const LogString *state_to_string(State state); @@ -175,8 +179,7 @@ class MitsubishiCN105 { Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; - bool use_temperature_encoding_b_{false}; - bool set_wide_vane_high_bit_{false}; + PropertyContext property_context_; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 13e02668d1..197e1e1bb5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -133,9 +133,7 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } } - if (this->parent_->is_status_initialized()) { - this->apply_values_(); - } + this->parent_->publish_status(); } void MitsubishiCN105Climate::apply_values_() { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 2319ea7c54..1caf779f40 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -38,6 +38,12 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { this->status_callback_.add(std::forward(callback)); } + void publish_status() { + if (this->is_status_initialized()) { + this->status_callback_.call(); + } + } + protected: MitsubishiCN105 hp_; CallbackManager status_callback_; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h new file mode 100644 index 0000000000..1f5faf61af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +template struct LookupMap { + using value_type = decltype(Unknown); + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == Unknown) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +struct Property { + using PropertyId = MitsubishiCN105::PropertyId; + using Status = MitsubishiCN105::Status; + using PropertyContext = MitsubishiCN105::PropertyContext; + + struct Power { + static constexpr auto ID = PropertyId::POWER; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.power_on = payload[2] != 0; + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x01; + payload[3] = status.power_on ? 0x01 : 0x00; + } + }; + + struct Temperature { + struct Target { + static constexpr auto ID = PropertyId::TEMPERATURE; + static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.use_temperature_encoding_b = payload[10] != 0; + } + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x04; + if (ctx.use_temperature_encoding_b) { + payload[14] = static_cast(std::round(status.target_temperature * 2.0f) + 128); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature)); + } + } + }; + + struct Room { + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.room_temperature = Temperature::decode(payload[2], payload[5], 10); + } + }; + + struct Remote { + static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE; + + static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) { + if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) { + payload[3] = 0x80; + } else { + payload[1] = 0x01; + payload[2] = static_cast(remote_temperature_half_deg - 16); + payload[3] = static_cast(remote_temperature_half_deg + 128); + } + } + }; + + protected: + static constexpr float decode(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; + } + }; + + template struct Lookup { + using Value = std::remove_cvref_t().*Field)>; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx)); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + uint8_t raw; + if (Derived::MAP.reverse_lookup(status.*Field, raw)) { + Derived::encode_raw(payload, raw, ctx); + } + } + + template static bool validate_and_set(Value value, Status &status, Mask &mask) { + uint8_t raw; + if (!Derived::MAP.reverse_lookup(value, raw)) { + return false; + } + status.*Field = value; + mask.set(Derived::ID); + return true; + } + + private: + friend Derived; + constexpr Lookup() = default; + }; + + struct Mode : Lookup { + static constexpr auto ID = PropertyId::MODE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { + const bool i_see = payload[3] > 0x08; + return payload[3] - (i_see ? 0x08 : 0); + } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x02; + payload[4] = raw; + } + }; + + struct FanMode : Lookup { + static constexpr auto ID = PropertyId::FAN; + static constexpr auto MAP = make_map({ + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x08; + payload[6] = raw; + } + }; + + struct VaneMode : Lookup { + static constexpr auto ID = PropertyId::VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x10; + payload[7] = raw; + } + }; + + struct WideVaneMode : Lookup { + static constexpr auto ID = PropertyId::WIDE_VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C + }); + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80; + } + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) { + payload[2] |= 0x01; + payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw; + } + }; + + template struct Decoder { + const std::span payload; + PropertyContext &context; + const Mask &pending_writes; + + bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) { + if (this->payload.size() <= 10) { + return false; + } + this->decode_(status); + return true; + } + + bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) { + if (this->payload.size() <= 5) { + return false; + } + this->decode_(status); + return true; + } + + protected: + template ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) { + T::decode_context(this->context, this->payload.data()); + if constexpr (requires { T::ID; }) { + if (this->pending_writes.contains(T::ID)) { + return; + } + } + T::decode(out, this->payload.data(), this->context); + } + + template void ESPHOME_ALWAYS_INLINE decode_(Out &out) { + (this->decode_one_(out), ...); + } + }; + + template struct Encoder { + uint8_t *payload; + const PropertyContext &context; + Mask &pending_writes; + + void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) { + this->payload[0] = 0x01; + this->encode_and_clear_(status); + } + + void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) { + this->payload[0] = 0x07; + this->encode_and_clear_(remote_temperature_half_deg); + } + + protected: + template void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) { + (this->encode_one_(in), ...); + (this->pending_writes.clear(T::ID), ...); + } + + template void encode_one_(const In &in) { + if (this->pending_writes.contains(T::ID)) { + T::encode(this->payload, in, this->context); + } + } + }; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py new file mode 100644 index 0000000000..a2e0353f85 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import ( + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +DEPENDENCIES = ["mitsubishi_cn105"] + +CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" + +# The insertion order must match VALUES in mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = ["Auto", "1", "2", "3", "4", "5", "Swing"] + +MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( + "MitsubishiCN105VerticalVaneDirectionSelect", + select.Select, + cg.Component, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema( + MitsubishiCN105VerticalVaneDirectionSelect, + icon="mdi:arrow-up-down", + ), + } +).extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION): + var = cg.new_Pvariable(vertical_vane_direction[CONF_ID]) + await cg.register_component(var, vertical_vane_direction) + await select.register_select( + var, + vertical_vane_direction, + options=VERTICAL_VANE_DIRECTIONS, + ) + await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp new file mode 100644 index 0000000000..0f9142fe5e --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -0,0 +1,39 @@ +#include "mitsubishi_cn105_vane_select_vertical.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in select.py. +// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based +// Select API, so Python option order and this array must stay aligned. +static constexpr std::array VALUES{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2, + MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, +}; + +void MitsubishiCN105VerticalVaneDirectionSelect::setup() { + this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); }); + if (this->parent_->is_status_initialized()) { + this->publish_vane_state(this->parent_->status().vane_mode); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) { + if (index < VALUES.size()) { + this->parent_->set_vane_mode(VALUES[index]); + this->parent_->publish_status(); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) { + for (size_t i = 0; i < VALUES.size(); ++i) { + if (VALUES[i] == mode) { + this->publish_state(i); + return; + } + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h new file mode 100644 index 0000000000..76977d59d7 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../mitsubishi_cn105_component.h" + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select, + public Component, + public Parented { + public: + void setup() override; + void publish_vane_state(MitsubishiCN105::VaneMode mode); + + protected: + void control(size_t index) override; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 14f4ca8a4d..16b956d7b5 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType from .const import ( CONF_ALLOW_PARTIAL_READ, + CONF_BITS, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -34,6 +35,7 @@ ModbusServer = modbus_server_ns.class_( ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_server_ns.struct("ServerRegister") +ServerBit = modbus_server_ns.class_("ServerBit") SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( { @@ -64,6 +66,32 @@ ModbusServerRegisterSchema = cv.Schema( ) +ModbusServerBitSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerBit), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +def _validate_unique_bit_addresses(config: ConfigType) -> ConfigType: + # Coils and discrete inputs share one bit address space (like holding/input registers share the + # register table), so each bit address may appear only once. + seen: set[int] = set() + for bit in config.get(CONF_BITS, []): + address = bit[CONF_ADDRESS] + if address in seen: + raise cv.Invalid( + f"Bit address 0x{address:04X} is configured more than once; coils and discrete " + "inputs share one bit address space, so each address must be unique", + path=[CONF_BITS], + ) + seen.add(address) + return config + + def _validate_register_ranges(config: ConfigType) -> ConfigType: # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit # Modbus address space (0x0000-0xFFFF). @@ -107,10 +135,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), + cv.Optional(CONF_BITS): cv.ensure_list(ModbusServerBitSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), _validate_register_ranges, _validate_no_overlapping_registers, + _validate_unique_bit_addresses, ) @@ -152,7 +182,7 @@ async def to_code(config): await cg.process_lambda( server_register[CONF_READ_LAMBDA], [(cg.uint16, "address")], - return_type=cpp_type, + return_type=cg.optional.template(cpp_type), ), ) ) @@ -170,5 +200,27 @@ async def to_code(config): if server_register[CONF_ALLOW_PARTIAL_READ]: cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) + for server_bit in config.get(CONF_BITS, []): + server_bit_var = cg.new_Pvariable(server_bit[CONF_ID], server_bit[CONF_ADDRESS]) + cg.add( + server_bit_var.set_read_lambda( + await cg.process_lambda( + server_bit[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cg.optional.template(cg.bool_), + ) + ) + ) + if (write_lambda := server_bit.get(CONF_WRITE_LAMBDA)) is not None: + cg.add( + server_bit_var.set_write_lambda( + await cg.process_lambda( + write_lambda, + parameters=[(cg.uint16, "address"), (cg.bool_, "x")], + return_type=cg.bool_, + ) + ) + ) + cg.add(var.add_server_bit(server_bit_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f2a8c53f45..86366c7ce0 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,4 +5,5 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_BITS = "bits" CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e63495cb25..feb0e67725 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -33,6 +33,12 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); + // No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement + // the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers + // ILLEGAL_DATA_ADDRESS below. + if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled) + return ExceptionCode::ILLEGAL_FUNCTION; + const uint32_t end_address = static_cast(start_address) + number_of_registers; uint32_t current_address = start_address; while (current_address < end_address) { @@ -75,7 +81,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u return ExceptionCode::ILLEGAL_DATA_ADDRESS; } - int64_t value = server_register->read_lambda(); + const optional read_value = server_register->read_lambda(); + if (!read_value.has_value()) { + ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.", + server_register->address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + const int64_t value = *read_value; char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), @@ -106,6 +118,11 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); + // No registers configured (e.g. a bits-only server): this device does not implement the register-write + // function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS. + if (this->server_registers_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + auto for_each_register = [this, start_address, ®isters](const std::function &callback) -> bool { @@ -167,6 +184,83 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, return {}; } +ServerBit *ModbusServer::find_bit_(uint16_t address) const { + for (auto *server_bit : this->server_bits_) { + if (server_bit->address == address) { + return server_bit; + } + } + return nullptr; +} + +modbus::ResponseStatus ModbusServer::on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) { + ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.", + this->address_, start_address, bits.size()); + + // No bits configured: this device does not implement the coil/discrete-input function, so answer + // ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); // range pre-checked by the hub + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->read_lambda) { + ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + const optional value = server_bit->read_lambda(address); + if (!value.has_value()) { + ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + bits.set(i, *value); + } + return {}; +} + +modbus::ResponseStatus ModbusServer::on_write_coils(uint16_t start_address, modbus::PackedBits bits) { + ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_, + start_address, bits.size()); + + // No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather + // than ILLEGAL_DATA_ADDRESS. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + // Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write + // before discovering a problem (mirrors the register write's two passes). + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // bits this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + } + + // Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather + // than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the + // register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight + // can never turn this into a silent null dereference. The only expected failure is a write callback + // rejecting the value at runtime, which cannot be rolled back. + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + if (!server_bit->write_lambda(address, bits[i])) { + ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + } + return {}; +} + void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, "ModbusServer:\n" @@ -184,6 +278,11 @@ void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } + ESP_LOGCONFIG(TAG, "server bits"); + for (auto &b : this->server_bits_) { + ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false", + b->write_lambda ? "true" : "false"); + } #endif } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f6484d8e6b..22903abfad 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -20,7 +20,7 @@ struct ServerCourtesyResponse { }; class ServerRegister { - using ReadLambda = std::function; + using ReadLambda = std::function()>; using WriteLambda = std::function; public: @@ -30,13 +30,18 @@ class ServerRegister { this->register_count = register_count; } - template void set_read_lambda(const std::function &&user_read_lambda) { - this->read_lambda = [this, user_read_lambda]() -> int64_t { - T user_value = user_read_lambda(this->address); + /// The user lambda returns optional: an empty optional declines the read, answering the whole + /// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly. + template void set_read_lambda(const std::function(uint16_t address)> &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> optional { + const optional user_value = user_read_lambda(this->address); + if (!user_value.has_value()) { + return {}; + } if constexpr (std::is_same_v) { - return bit_cast(user_value); + return bit_cast(*user_value); } else { - return static_cast(user_value); + return static_cast(*user_value); } }; } @@ -97,17 +102,43 @@ class ServerRegister { WriteLambda write_lambda; }; +/// A single bit in the server's coil/discrete-input table. Coils (0x01/0x05/0x0F) and discrete +/// inputs (0x02) share one bit address space, mirroring how holding and input registers share the +/// register table: both read function codes are served from the same bits. +class ServerBit { + /// Returning an empty optional declines the read: the whole request is answered with a + /// SERVICE_DEVICE_FAILURE exception. `return true;`/`return false;` convert implicitly. + using ReadLambda = std::function(uint16_t address)>; + using WriteLambda = std::function; + + public: + explicit ServerBit(uint16_t address) : address(address) {} + void set_read_lambda(ReadLambda &&read_lambda) { this->read_lambda = std::move(read_lambda); } + void set_write_lambda(WriteLambda &&write_lambda) { this->write_lambda = std::move(write_lambda); } + + uint16_t address{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// Registers a server bit with the controller. Called by esphomes code generator + void add_server_bit(ServerBit *server_bit) { server_bits_.push_back(server_bit); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; + /// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are + /// served from the same bit table (see ServerBit) + modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final; + /// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors + modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; @@ -118,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { protected: /// Find the registered value whose register span contains address, or nullptr if none does. ServerRegister *find_containing_register_(uint32_t address) const; + /// Find the registered bit at address, or nullptr if none is. + ServerBit *find_bit_(uint16_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; + /// Collection of all server bits (coils/discrete inputs) for this component + std::vector server_bits_{}; /// Server courtesy response ServerCourtesyResponse server_courtesy_response_{ .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 7dd84d9c31..80e8bf9415 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -2,6 +2,7 @@ #ifdef USE_RP2040_BLE +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -180,7 +181,7 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, // ESPHome main loop: bounded copy into the lock-free queue only. bd_addr_t addr; // accessor returns printable (MSB-first) order gap_event_advertising_report_get_address(packet, addr); - uint8_t mac_lsb[6]; + uint8_t mac_lsb[MAC_ADDRESS_SIZE]; reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), gap_event_advertising_report_get_address_type(packet), @@ -206,7 +207,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac_lsb_first, 6); + memcpy(report->mac, mac_lsb_first, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->adv_event_type = adv_event_type; @@ -217,7 +218,9 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, } // NOLINTEND(clang-analyzer-unix.Malloc) -void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); } +void RP2040BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + memcpy(out, this->ble_mac_, MAC_ADDRESS_SIZE); +} bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) { diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 99eb8cd88a..263a32106b 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -25,8 +25,8 @@ enum class BLEComponentState : uint8_t { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart uint8_t data_len; // bytes valid in data[] @@ -77,7 +77,7 @@ class RP2040BLE final : public Component { /// (LSB-first) order, hence the explicit names. All zeros until the stack /// reports ACTIVE (BTstack reads the address from the controller during /// power-up). - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef RP2040_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main loop via loop()). @@ -135,7 +135,7 @@ class RP2040BLE final : public Component { btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - uint8_t ble_mac_[6]{0}; // printable (MSB-first) order; zeros until ACTIVE + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // printable (MSB-first) order; zeros until ACTIVE BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 02bd7dc145..431f2daec7 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -71,7 +71,7 @@ class RP2BLETracker : public Component, } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scan_running_; } bool scan_active() { return this->scan_active_; } bool request_scan_mode(bool active); diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0a05950c5a..06c3a7ab7a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -293,7 +293,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c return false; } - uint8_t mac_reverse[6] = {0}; + uint8_t mac_reverse[MAC_ADDRESS_SIZE] = {0}; mac_reverse[5] = (uint8_t) (address >> 40); mac_reverse[4] = (uint8_t) (address >> 32); mac_reverse[3] = (uint8_t) (address >> 24); @@ -358,7 +358,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c #endif if (!decrypt_ok) { - uint8_t mac_address[6] = {0}; + uint8_t mac_address[MAC_ADDRESS_SIZE] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); memcpy(mac_address + 2, mac_reverse + 3, 1); diff --git a/requirements.txt b/requirements.txt index 98c2f47d7e..a90d9ec9eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.7.0 +aioesphomeapi==45.8.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 @@ -27,7 +27,7 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.0 # native esp-idf toolchain global cache dir +platformdirs==4.11.1 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a6ccb79544..b8ee3066bd 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -52,6 +52,7 @@ COMMON_BUS_PATH = ( # the packages on the right as well PACKAGE_DEPENDENCIES = { "modbus": ["uart"], # modbus packages include uart packages + "modbus_server": ["uart"], # modbus_server packages include uart packages # Add more package dependencies here as needed } diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py index 3e041c6d4a..ce1098fbca 100644 --- a/tests/component_tests/modbus_server/test_modbus_server.py +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -7,8 +7,13 @@ from esphome.components.modbus_server import ( SERVER_SENSOR_VALUE_TYPE, _validate_no_overlapping_registers, _validate_register_ranges, + _validate_unique_bit_addresses, +) +from esphome.components.modbus_server.const import ( + CONF_BITS, + CONF_REGISTERS, + CONF_VALUE_TYPE, ) -from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE from esphome.const import CONF_ADDRESS @@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict: } +def _bits_config(addresses: list[int]) -> dict: + return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]} + + def test_non_overlapping_registers_pass() -> None: # Values that tile the address space without gaps or overlaps are accepted. config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) @@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None: _validate_no_overlapping_registers(config) +def test_unique_bit_addresses_pass() -> None: + config = _bits_config([0x00, 0x01, 0x02]) + assert _validate_unique_bit_addresses(config) is config + + +def test_duplicate_bit_address_rejected() -> None: + # Coils and discrete inputs share one bit address space, so a repeated address is rejected. + config = _bits_config([0x05, 0x05]) + with pytest.raises(cv.Invalid, match="more than once"): + _validate_unique_bit_addresses(config) + + def test_multi_register_value_overlapping_neighbour_rejected() -> None: # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml new file mode 100644 index 0000000000..3b77eed7ea --- /dev/null +++ b/tests/components/hoermann_hcp/common.yaml @@ -0,0 +1,8 @@ +hoermann_hcp: + id: hoermann_hcp_hub + modbus_id: modbus_server_bus + +cover: + - platform: hoermann_hcp + name: Garage Door + device_class: garage diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp new file mode 100644 index 0000000000..0ec2ed1ddd --- /dev/null +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -0,0 +1,174 @@ +#include + +#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// The door only accepts commands once the bus controller has actually talked to it. +void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } + +// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. +uint16_t poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + return response.size() == 8u ? response[2] : 0xFFFF; +} + +} // namespace + +// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. +TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN); + + // Any request marks the device connected, which is itself a state change. + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED); +} + +// Venting and half-open moves report no direction, so one is only derived once the position has moved. +TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // Position 100/200 = 0.5, state 0x80 -> resting half open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000})); + door.update(); + ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // State 0x05 -> moving to half-open, but the position has not moved yet. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // Position 120/200 = 0.6 is higher than before, so the door is opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING); + EXPECT_FLOAT_EQ(cover.position, 0.6f); +} + +// Booting while the door is already mid-move gives no baseline to compare against, so no direction +// may be inferred from the first update. +TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // The very first thing seen is a half-open move already at 100/200 = 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); +} + +// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather +// than as a target the door would be stopped at. +TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_open().perform(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed +} + +// The same for cover.close, which arrives as a position of 0.0. +TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_close().perform(); + EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed +} + +TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_command_toggle().perform(); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + // The door is opening, so it takes an impulse to stop it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + + cover.make_call().set_command_stop().perform(); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// A position between the end stops starts the door in the right direction; it is stopped there later. +TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { + HoermannHcp door; // starts out fully closed + HoermannHcpCover cover(&door); + cover.setup(); + connect(door); + + cover.make_call().set_position(0.5f).perform(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed +} + +// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has +// to be published back over that assumption. +TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + + cover.make_call().set_command_close().perform(); + + EXPECT_EQ(poll_command(door), 0x0000); + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); +} + +// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would +// otherwise sit at its fully open default and look healthy. +TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + EXPECT_TRUE(cover.status_has_warning()); + + connect(door); + door.update(); + EXPECT_FALSE(cover.status_has_warning()); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp new file mode 100644 index 0000000000..8463c3f605 --- /dev/null +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -0,0 +1,430 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +using modbus::RegisterValues; + +namespace { + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// The device only accepts commands once the bus controller has actually talked to it. +void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } + +// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value. +uint16_t poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + return response.size() == 8u ? response[2] : 0xFFFF; +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::set_valid_; +}; + +} // namespace + +// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. +TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 2, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 2u); + EXPECT_EQ(response[0], 0x0004); + EXPECT_EQ(response[1], 0x0000); +} + +// A bus scan (write 3 / read 5) answers with the fixed device identification block. +TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 5, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 5u); + EXPECT_EQ(response[1], 0x0005); + EXPECT_EQ(response[2], 0x0430); + EXPECT_EQ(response[3], 0x10ff); + EXPECT_EQ(response[4], 0xa845); +} + +// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press. +TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0001); + EXPECT_EQ(response[2], 0x0000); + EXPECT_EQ(response[3], 0x0000); +} + +// A queued control command is injected into the next command poll as a simulated key press. +TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { + HoermannHcp door; + connect(door); + door.open_door(); + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value + EXPECT_EQ(response[3], 0x0000); +} + +// A read of any other block is an addressing error rather than a successful all-zero reply. +TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { + HoermannHcp door; + RegisterValues response; + EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A command is held for the key-press duration, then released, and only then can the next one be queued. +TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { + TestableHoermannHcp door; + connect(door); + door.open_door(); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. + door.close_door(); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + // With the command gone, the next one is accepted again. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed +} + +// Commands issued while the bus controller is absent are dropped instead of firing when it returns. +TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { + HoermannHcp door; + door.open_door(); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// Losing the controller must drop a command it never fetched, otherwise it blocks every later command +// and fires unasked once the bus comes back. +TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { + TestableHoermannHcp door; + connect(door); + door.open_door(); + ASSERT_TRUE(door.is_valid()); + + door.set_valid_(false); + EXPECT_FALSE(door.is_valid()); + + // The reconnecting poll must not replay the dropped command. + EXPECT_EQ(poll_command(door), 0x0000); + // And the slot is free, so a new command is accepted. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); +} + +// The connection is dropped by update() once the controller stops polling, which is what releases a +// command it never fetched in the field. +TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { + TestableHoermannHcp door; + // Wide enough that a stall cannot expire the connection before the check below runs. + door.connection_timeout_ms_ = 10000; + connect(door); + door.open_door(); + + // Still inside the window: the controller counts as present. + door.update(); + ASSERT_TRUE(door.is_valid()); + + // Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer. + door.connection_timeout_ms_ = 20; + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + EXPECT_FALSE(door.is_valid()); + // The pending command went with the connection instead of firing on the reconnecting poll. + EXPECT_EQ(poll_command(door), 0x0000); +} + +// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to +// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely. +TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect(door); + door.open_door(); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // A status broadcast refreshes the connection without ever fetching the command. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + ASSERT_TRUE(door.is_valid()); + + // With the stale command gone, the door accepts commands again. + door.close_door(); + EXPECT_EQ(poll_command(door), 0x0220); +} + +// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed +// differently per block length. +TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) { + HoermannHcp door; + // Counter 0x34 in the high byte, command 0x07 in the low byte. + door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000})); + + RegisterValues command_poll; + door.on_read_holding_registers(STATE_REG, 8, command_poll); + ASSERT_EQ(command_poll.size(), 8u); + EXPECT_EQ(command_poll[0], 0x3400); // counter alone + EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low + + RegisterValues empty_poll; + door.on_read_holding_registers(STATE_REG, 2, empty_poll); + ASSERT_EQ(empty_poll.size(), 2u); + EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here + EXPECT_EQ(empty_poll[1], 0x0700); // command alone + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + ASSERT_EQ(scan.size(), 5u); + EXPECT_EQ(scan[0], 0x3400); + EXPECT_EQ(scan[1], 0x0705); +} + +// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position. +TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) { + HoermannHcp door; + // registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening). + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a +// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed. +TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) { + HoermannHcp door; + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::STOPPED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of +// the state register still has to be decoded. +TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) { + HoermannHcp door; + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061})); + EXPECT_EQ(door.get_door_state(), DoorState::VENT); +} + +// A door parking a count short of its end stop must still report exactly closed or open, because +// Cover::is_fully_closed() compares against 0.0 exactly. +TEST(HoermannHcpWrite, EndStopsReportExactPositions) { + HoermannHcp door; + // Position register 1 of 200 while the door reports itself closed. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f); + + // Position register 199 of 200 while the door reports itself open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f); + + // Away from the end stops the raw count is reported as-is. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// A position request below the lower snap threshold becomes a plain close command. +TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { + HoermannHcp door; + connect(door); + door.set_position(0.02f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value +} + +// A half-open target starts the door moving towards the requested position. +TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { + HoermannHcp door; // starts out fully closed + connect(door); + door.set_position(0.5f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value +} + +// The door has no notion of a target, so it is stopped with an impulse once it travels past the request. +TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + + // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_EQ(poll_command(door), 0x0000); + + // Position 120/200 = 0.6 is past the target, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once +// must be read as "already stopped" rather than "still opening". +TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { + TestableHoermannHcp door; + connect(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + // The door is stopped at 0.3 by a wall button, short of the requested 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); +} + +// A target armed while the door is still travelling the other way must not be judged by that old direction, +// otherwise the very next position it reports counts as reached and stops the door where it stands. +TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { + TestableHoermannHcp door; + connect(door); + // The door is closing, passing 60/200 = 0.3. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released + + // Still closing at 58/200 = 0.29: below the target, but not on the way to it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); + EXPECT_EQ(poll_command(door), 0x0000); + + // Now opening at 62/200 = 0.31, still short of the target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); + + // Past the target at 110/200 = 0.55, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed +} + +// A motor turning around can report a momentary stop; dropping the target there would let the door run on +// to the end stop that the reversing command asked for. +TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { + TestableHoermannHcp door; + connect(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + // The stop reported on the way from closing to opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // The door then opens and still has to be stopped at the requested position. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0240); +} + +// A door that never turns around has to lose the target as well, otherwise it would cut a later move short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door), 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door), 0x0110); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the + // target is the only thing that may expire here. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000})); + door.update(); + ASSERT_TRUE(door.is_valid()); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door), 0x0000); +} + +} // namespace esphome::hoermann_hcp diff --git a/tests/components/hoermann_hcp/test.esp32-idf.yaml b/tests/components/hoermann_hcp/test.esp32-idf.yaml new file mode 100644 index 0000000000..ce3aa2437a --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/hoermann_hcp/test.esp8266-ard.yaml b/tests/components/hoermann_hcp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8f7ba81b5b --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7703b02fcd..3bc6d5b2b8 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -2,16 +2,16 @@ namespace esphome::mitsubishi_cn105::testing { -struct TestContext { +struct MitsubishiCN105TestsContext { MockUARTComponent uart; uart::UARTDevice device{&uart}; TestableMitsubishiCN105 sut{device}; - TestContext() { this->sut.set_current_time(0); } + MitsubishiCN105TestsContext() { this->sut.set_current_time(0); } }; TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); @@ -26,7 +26,7 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -106,7 +106,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -133,7 +133,7 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -164,7 +164,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { } TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -228,7 +228,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { } TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); @@ -258,7 +258,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); @@ -266,14 +266,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { ctx.sut.update(); EXPECT_TRUE(ctx.sut.status().power_on); - EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_); + EXPECT_FALSE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET); } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); @@ -281,14 +281,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { ctx.sut.update(); EXPECT_FALSE(ctx.sut.status().power_on); - EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_); + EXPECT_TRUE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4); } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); @@ -298,7 +298,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); @@ -308,7 +308,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58}); @@ -316,11 +316,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_FALSE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8}); @@ -328,11 +328,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_TRUE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_power(true); ctx.sut.apply_settings(); @@ -342,7 +342,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_target_temperature(23.0f); ctx.sut.apply_settings(); @@ -352,9 +352,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.0f); ctx.sut.apply_settings(); @@ -363,9 +363,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.5f); ctx.sut.apply_settings(); @@ -374,7 +374,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplyModeCool) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); ctx.sut.apply_settings(); @@ -384,7 +384,7 @@ TEST(MitsubishiCN105Tests, ApplyModeCool) { } TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1); ctx.sut.apply_settings(); @@ -394,7 +394,7 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { } TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); ctx.sut.apply_settings(); @@ -404,7 +404,7 @@ TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -414,9 +414,9 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.property_context_.set_wide_vane_high_bit = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -425,7 +425,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { } TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -445,7 +445,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -470,7 +470,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { } TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Set remote temperature ctx.sut.set_remote_temperature(28.5f); @@ -505,10 +505,10 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { } TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -521,11 +521,11 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::POWER)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::MODE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::FAN)); // ACK the first write. Remote temperature should still be pending afterward. ctx.uart.tx.clear(); @@ -533,7 +533,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); ASSERT_FALSE(ctx.sut.update()); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); // The next apply sends the remote-temperature packet and clears the last pending flag. ctx.uart.tx.clear(); @@ -545,7 +545,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { } TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -557,7 +557,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -578,28 +578,28 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(40.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(NAN); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index a14043c737..b90ddf3995 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -47,12 +47,11 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; using MitsubishiCN105::State; - using MitsubishiCN105::UpdateFlag; + using MitsubishiCN105::PropertyId; using MitsubishiCN105::state_; using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; - using MitsubishiCN105::use_temperature_encoding_b_; - using MitsubishiCN105::set_wide_vane_high_bit_; + using MitsubishiCN105::property_context_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5966523b34..12a3b8ce9d 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -10,6 +10,12 @@ climate: name: "AC Test" supported_swing_modes: BOTH +select: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac + vertical_vane_direction: + name: "Vertical Vane" + esphome: on_boot: then: diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp new file mode 100644 index 0000000000..4c980d69d8 --- /dev/null +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -0,0 +1,111 @@ +#include "../common.h" +#include "esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h" + +namespace esphome::mitsubishi_cn105::testing { + +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } +}; + +class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { + public: + using MitsubishiCN105VerticalVaneDirectionSelect::control; +}; + +struct VerticalVaneDirectionSelectTestContext { + TestableMitsubishiCN105Component hub; + TestableMitsubishiCN105VerticalVaneDirectionSelect select; + + VerticalVaneDirectionSelectTestContext() { + this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"}); + this->select.set_parent(&this->hub); + this->select.setup(); + } +}; + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array expected_modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < expected_modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.select.control(i); + EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]); + } +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.hub.mutable_status().vane_mode = modes[i]; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{i}); + } + + ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndClimateThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + ctx.select.control(6); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + ctx.select.control(3); + EXPECT_EQ(ctx.select.active_index(), std::optional{3}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSelectThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{0}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) { + VerticalVaneDirectionSelectTestContext ctx; + + ctx.select.control(3); + + EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3); + EXPECT_FALSE(ctx.select.has_state()); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 1f3a8f551b..3f84a3f6da 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -15,6 +15,16 @@ modbus_server: - id: modbus_server3 address: 0x3 modbus_id: mod_bus2 + bits: + - address: 0x0 + read_lambda: |- + return true; + - address: 0x1 + read_lambda: |- + return address == 0x1; + write_lambda: |- + printf("bit address=%d, value=%d\n", (int) address, (int) x); + return true; registers: - address: 0x9 value_type: S_DWORD diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 2137a77f3d..ce39e83736 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } -// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +// A write to an address not covered by any configured register (on a populated server) yields +// ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [](int64_t) { return true; }; + server.add_server_register(®); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION. +TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + // A write_lambda failing at runtime is the one non-atomic case: the earlier register is already // applied, and the handler reports SERVICE_DEVICE_FAILURE. TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { @@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { EXPECT_EQ(out[1], 0xABCD); } -// An unregistered address with courtesy disabled is rejected. +// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS. TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + RegisterValues out; auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); @@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured (courtesy disabled) does not implement the register-read +// function: ILLEGAL_FUNCTION. +TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + RegisterValues out; + auto status = server.on_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + +// A register read lambda returning an empty optional declines the read: the whole request is +// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda so the optional-forwarding wrapper +// (not a hand-assigned read_lambda) is what carries the decline through. +TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.set_read_lambda([](uint16_t address) -> optional { return {}; }); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + // --- partial reads (opt-in) ---------------------------------------------------- // With allow_partial_read, reading only the first register of a DWORD returns its high word. @@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) { EXPECT_EQ(second[0], 0x1234); } +// --- bits (coils / discrete inputs, one shared address space) ------------------- + +// Bits are read through the shared table regardless of which read function code arrived: +// the hub routes both 0x01 and 0x02 to on_read_bits(). +TEST(ModbusServerBits, ReadSetsRequestedBits) { + ModbusServer server; + ServerBit bit0(0x0000); + bit0.set_read_lambda([](uint16_t) { return true; }); + ServerBit bit1(0x0001); + bit1.set_read_lambda([](uint16_t) { return false; }); + ServerBit bit2(0x0002); + bit2.set_read_lambda([](uint16_t) { return true; }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + server.add_server_bit(&bit2); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0b101); +} + +// The read lambda receives the bit's address, so one lambda can serve several bits. +TEST(ModbusServerBits, ReadLambdaReceivesAddress) { + ModbusServer server; + ServerBit server_bit(0x0007); + server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; }); + server.add_server_bit(&server_bit); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0x01); +} + +// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerBits, UnreadableBitRejectsRead) { + ModbusServer server; + ServerBit readable(0x0000); + readable.set_read_lambda([](uint16_t) { return true; }); + ServerBit write_only(0x0001); + write_only.set_write_lambda([](uint16_t, bool) { return true; }); + server.add_server_bit(&readable); + server.add_server_bit(&write_only); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1)); + EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A read lambda returning an empty optional declines the read: the whole request is answered +// with SERVICE_DEVICE_FAILURE. +TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerBit ok(0x0000); + ok.set_read_lambda([](uint16_t) { return true; }); + ServerBit declining(0x0001); + declining.set_read_lambda([](uint16_t) -> optional { return {}; }); + server.add_server_bit(&ok); + server.add_server_bit(&declining); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + +// A multi-coil write applies every bit and reports success. +TEST(ModbusServerBits, WriteAppliesAllBits) { + ModbusServer server; + bool state[2] = {false, true}; + ServerBit bit0(0x0000); + bit0.set_write_lambda([&state](uint16_t, bool value) { + state[0] = value; + return true; + }); + ServerBit bit1(0x0001); + bit1.set_write_lambda([&state](uint16_t, bool value) { + state[1] = value; + return true; + }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + + const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_FALSE(status.has_value()); + EXPECT_TRUE(state[0]); + EXPECT_FALSE(state[1]); +} + +// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any +// bit is applied. +TEST(ModbusServerBits, UnwritableBitAppliesNothing) { + ModbusServer server; + bool written = false; + ServerBit writable(0x0000); + writable.set_write_lambda([&written](uint16_t, bool) { + written = true; + return true; + }); + ServerBit read_only(0x0001); + read_only.set_read_lambda([](uint16_t) { return false; }); + server.add_server_bit(&writable); + server.add_server_bit(&read_only); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(written); // the writable bit must NOT have been applied +} + +// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and +// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior). +TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerBit first(0x0000); + first.set_write_lambda([&first_written](uint16_t, bool) { + first_written = true; + return true; + }); + ServerBit second(0x0001); + second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime + server.add_server_bit(&first); + server.add_server_bit(&second); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); +} + } // namespace esphome::modbus_server diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml index 167ad2c5bb..e445093625 100644 --- a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -133,8 +133,8 @@ button: on_error: then: - lambda: "id(error_code).publish_state((int) exception_code);" - # The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read - # action's request PDU and its typed error delivery. + # The mock server maps no bits, so it does not implement the coil function: a coil read draws + # ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery. - modbus_client.read_coils: address: 1 start_address: 0x00 @@ -166,7 +166,7 @@ button: on_not_sent: then: - lambda: "id(not_sent_flag).publish_state(1);" - # Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION. + # Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION. - modbus_client.write_multiple_coils: address: 1 start_address: 0x00 diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml new file mode 100644 index 0000000000..cb6fc6f074 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml @@ -0,0 +1,147 @@ +esphome: + name: uart-mock-modbus-srv-bits + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; + +# The same four bits are read both as coils (FC 0x01) and as discrete inputs +# (FC 0x02): the server serves both from one shared bit table, so the two +# views must always agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 65af57b944..4d31644559 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -387,8 +387,9 @@ class SensorStateCollector: class SensorTracker: """Data-driven sensor state tracker with expected-value futures. - Tracks sensor state updates and resolves futures when sensors report - specific expected values. Eliminates per-sensor future boilerplate. + Tracks sensor and binary sensor state updates and resolves futures when + they report specific expected values. Eliminates per-sensor future + boilerplate. Usage:: @@ -421,7 +422,10 @@ class SensorTracker: def on_state(self, state: EntityState) -> None: """State callback suitable for ``subscribe_states``.""" - if not isinstance(state, SensorState) or state.missing_state: + if ( + not isinstance(state, (SensorState, BinarySensorState)) + or state.missing_state + ): return sensor_name = self.key_to_sensor.get(state.key) if not sensor_name or sensor_name not in self.sensor_states: diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 1994d02c34..17ab21f873 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -21,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, NumberInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest from .state_utils import SensorTracker, find_entity @@ -411,6 +411,68 @@ async def test_uart_mock_modbus_server_controller_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_bits( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test coil/discrete-input round trips between controller and server bits. + + The server serves four bits from one shared table. The controller reads + each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02), + so the two views must always agree. Two bits are then written back, one + via the single-coil write (FC 0x05) and one via the multiple-coils write + (FC 0x0F), and the new values must show up in both read views. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + initial_values = { + "bit_coil_0": True, + "bit_coil_1": False, + "bit_coil_2": False, + "bit_coil_3": True, + "bit_di_0": True, + "bit_di_1": False, + "bit_di_2": False, + "bit_di_3": True, + } + tracker = SensorTracker(list(initial_values.keys())) + + # Phase 1: expect initial baseline values in both read views + initial_futures = tracker.expect_all(initial_values) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + { + "bit_coil_2": True, + "bit_di_2": True, + "bit_coil_3": False, + "bit_di_3": False, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Flip both writable bits: 0x02 false -> true, 0x03 true -> false + for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): + entity = find_entity(entities, switch_name, SwitchInfo) + assert entity is not None, f"{switch_name} switch entity not found" + client.switch_command(entity.key, value) + + # Wait for both read views to reflect the written values + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -447,10 +509,11 @@ async def test_uart_mock_modbus_client_typed( with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); a read of unserved register 0x99 resolves via on_error with the device's exception code (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via - on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error - delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 - chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from - the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not + implement the coil function - proving the bit-read request and typed error delivery. A multi-register + write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler + (-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server + (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent (-> not_sent_flag). """ diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index a3c6f476e0..010313db7f 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -31,11 +31,14 @@ common/ │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml -├── modbus/ # Modbus (includes uart via packages) +├── modbus/ # Modbus client (includes uart via packages) │ ├── esp32-idf.yaml │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml +├── modbus_server/ # Modbus server (includes uart via packages) +│ ├── esp32-idf.yaml +│ └── esp8266-ard.yaml └── ble/ ├── esp32-idf.yaml ├── esp32-ard.yaml diff --git a/tests/test_build_components/common/modbus_server/esp32-idf.yaml b/tests/test_build_components/common/modbus_server/esp32-idf.yaml new file mode 100644 index 0000000000..093467ebfd --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP32 IDF tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp32-idf.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/test_build_components/common/modbus_server/esp8266-ard.yaml b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml new file mode 100644 index 0000000000..ab9cad8b56 --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP8266 Arduino tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp8266-ard.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server