Merge branch 'dev' into rp2-3-connection-slots

This commit is contained in:
J. Nick Koston
2026-08-10 21:47:31 -05:00
committed by GitHub
72 changed files with 2864 additions and 435 deletions
+1
View File
@@ -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
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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<uint8_t>(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<uint8_t>(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];
+10 -10
View File
@@ -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<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> 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
@@ -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];
@@ -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,
@@ -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
@@ -2,6 +2,8 @@
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "esphome/core/helpers.h"
#include <cstring>
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;
@@ -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)
@@ -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 ----
@@ -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;
}
@@ -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;
}
@@ -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<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
static_assert(static_cast<uint8_t>(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};
};
@@ -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 <BluetoothLock.h>
@@ -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;
@@ -40,7 +40,7 @@ static_assert(static_cast<uint32_t>(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<api::enums::BluetoothScannerState>(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<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
static_cast<BluetoothProxy *>(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) {
@@ -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<uint64_t>(static_cast<uint16_t>(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<int16_t>(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<char, 18> output) {
uint8_t mac[6] = {};
void get_bluetooth_mac_address_pretty(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> 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<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> 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<PendingDisconnect, BLUETOOTH_PROXY_MAX_CONNECTIONS> 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
};
+5 -5
View File
@@ -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"
+1 -1
View File
@@ -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; }
@@ -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);
@@ -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)
@@ -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)
@@ -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
@@ -0,0 +1,27 @@
#pragma once
#include <cmath>
#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
@@ -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 &registers, 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 &registers) {
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<uint16_t>((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<uint16_t>(0x0001 | command));
this->push_command_registers_(registers);
push_zeros(registers, 4);
break;
case 2:
// Empty command request.
registers.push_back(static_cast<uint16_t>(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<uint16_t>(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 &registers) {
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 &registers) {
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<uint8_t>(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<float>(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
@@ -0,0 +1,110 @@
#pragma once
#include <utility>
#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<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(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 &registers) override;
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) 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 &registers);
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<void()> 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
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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};
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -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<char, 18> buffer) {
inline const char *format_mac_str(const uint8_t *mac_address, std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer) {
if (mac_address_is_valid(mac_address)) {
format_mac_addr_upper(mac_address, buffer.data());
return buffer.data();
+3 -3
View File
@@ -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<int8_t>(-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);
}
// ---------------------------------------------------------------------------
+3 -3
View File
@@ -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<uint16_t> 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)
@@ -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];
@@ -4,6 +4,7 @@
#include <array>
#include <cmath>
#include <numeric>
#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<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
static constexpr auto UNKNOWN_VALUE = Unknown;
const std::array<value_type, N> 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<uint8_t>::max());
if (value == UNKNOWN_VALUE) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(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<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
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::UNKNOWN>({
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::UNKNOWN>({
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>({
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<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
}
@@ -124,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD);
void MitsubishiCN105::initialize() { this->set_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<const uint8_t> 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<uint8_t, REQUEST_PAYLOAD_LEN> payload = {this->current_status_msg_type_};
std::array<uint8_t, REQUEST_PAYLOAD_LEN> 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<uint8_t>(mode));
return;
if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) {
ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(wide_vane_mode));
}
this->status_.wide_vane_mode = wide_vane_mode;
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
}
void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> 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<uint8_t>(this->remote_temperature_half_deg_ - 16);
payload[3] = static_cast<uint8_t>(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<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] =
static_cast<uint8_t>(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));
@@ -5,6 +5,7 @@
#include <cmath>
#include <optional>
#include <span>
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<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
template<typename... Flags> 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, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
FiniteSetMask<PropertyId, DefaultBitPolicy<PropertyId, static_cast<int>(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<const uint8_t> 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<typename T> 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};
@@ -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_() {
@@ -38,6 +38,12 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice {
this->status_callback_.add(std::forward<F>(callback));
}
void publish_status() {
if (this->is_status_initialized()) {
this->status_callback_.call();
}
}
protected:
MitsubishiCN105 hp_;
CallbackManager<void()> status_callback_;
@@ -0,0 +1,302 @@
#pragma once
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
#include <type_traits>
#include <utility>
#include "mitsubishi_cn105.h"
namespace esphome::mitsubishi_cn105 {
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
const std::array<value_type, N> 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<uint8_t>::max());
if (value == Unknown) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{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<uint8_t>(std::round(status.target_temperature * 2.0f) + 128);
} else {
payload[5] = static_cast<uint8_t>(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<uint8_t>(remote_temperature_half_deg - 16);
payload[3] = static_cast<uint8_t>(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<typename Derived, auto Field> struct Lookup {
using Value = std::remove_cvref_t<decltype(std::declval<Status>().*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<typename Mask> 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<Mode, &Status::mode> {
static constexpr auto ID = PropertyId::MODE;
static constexpr auto MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
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<FanMode, &Status::fan_mode> {
static constexpr auto ID = PropertyId::FAN;
static constexpr auto MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
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<VaneMode, &Status::vane_mode> {
static constexpr auto ID = PropertyId::VANE;
static constexpr auto MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
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<WideVaneMode, &Status::wide_vane_mode> {
static constexpr auto ID = PropertyId::WIDE_VANE;
static constexpr auto MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
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<typename Mask> struct Decoder {
const std::span<const uint8_t> payload;
PropertyContext &context;
const Mask &pending_writes;
bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) {
if (this->payload.size() <= 10) {
return false;
}
this->decode_<Power, Temperature::Target, Mode, FanMode, VaneMode, WideVaneMode>(status);
return true;
}
bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) {
if (this->payload.size() <= 5) {
return false;
}
this->decode_<Temperature::Room>(status);
return true;
}
protected:
template<typename T, typename Out> 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<typename... T, typename Out> void ESPHOME_ALWAYS_INLINE decode_(Out &out) {
(this->decode_one_<T>(out), ...);
}
};
template<typename Mask> 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_<Power, Temperature::Target, WideVaneMode, VaneMode, Mode, FanMode>(status);
}
void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) {
this->payload[0] = 0x07;
this->encode_and_clear_<Temperature::Remote>(remote_temperature_half_deg);
}
protected:
template<typename... T, typename In> void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) {
(this->encode_one_<T>(in), ...);
(this->pending_writes.clear(T::ID), ...);
}
template<typename T, typename In> void encode_one_(const In &in) {
if (this->pending_writes.contains(T::ID)) {
T::encode(this->payload, in, this->context);
}
}
};
};
} // namespace esphome::mitsubishi_cn105
@@ -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)
@@ -0,0 +1,39 @@
#include "mitsubishi_cn105_vane_select_vertical.h"
#include <array>
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
@@ -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<MitsubishiCN105Component> {
public:
void setup() override;
void publish_vane_state(MitsubishiCN105::VaneMode mode);
protected:
void control(size_t index) override;
};
} // namespace esphome::mitsubishi_cn105
+53 -1
View File
@@ -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)
@@ -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"
@@ -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<uint32_t>(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<int64_t> 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<size_t>(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,
&registers](const std::function<bool(ServerRegister *, uint16_t register_offset)> &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<uint16_t>(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<bool> 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<uint16_t>(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<uint16_t>(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<uint8_t>(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
}
@@ -20,7 +20,7 @@ struct ServerCourtesyResponse {
};
class ServerRegister {
using ReadLambda = std::function<int64_t()>;
using ReadLambda = std::function<optional<int64_t>()>;
using WriteLambda = std::function<bool(int64_t value)>;
public:
@@ -30,13 +30,18 @@ class ServerRegister {
this->register_count = register_count;
}
template<typename T> void set_read_lambda(const std::function<T(uint16_t address)> &&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<T>: an empty optional declines the read, answering the whole
/// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly.
template<typename T> void set_read_lambda(const std::function<optional<T>(uint16_t address)> &&user_read_lambda) {
this->read_lambda = [this, user_read_lambda]() -> optional<int64_t> {
const optional<T> user_value = user_read_lambda(this->address);
if (!user_value.has_value()) {
return {};
}
if constexpr (std::is_same_v<T, float>) {
return bit_cast<uint32_t>(user_value);
return bit_cast<uint32_t>(*user_value);
} else {
return static_cast<int64_t>(user_value);
return static_cast<int64_t>(*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<optional<bool>(uint16_t address)>;
using WriteLambda = std::function<bool(uint16_t address, bool value)>;
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 &registers) 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 &registers) 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<ServerRegister *> server_registers_{};
/// Collection of all server bits (coils/discrete inputs) for this component
std::vector<ServerBit *> server_bits_{};
/// Server courtesy response
ServerCourtesyResponse server_courtesy_response_{
.enabled = false, .register_last_address = 0xFFFF, .register_value = 0};
+6 -3
View File
@@ -2,6 +2,7 @@
#ifdef USE_RP2040_BLE
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <BluetoothLock.h>
@@ -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<int8_t>(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()) {
+4 -4
View File
@@ -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};
@@ -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);
+2 -2
View File
@@ -293,7 +293,7 @@ bool decrypt_xiaomi_payload(std::vector<uint8_t> &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<uint8_t> &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);
+2 -2
View File
@@ -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
+1
View File
@@ -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
}
@@ -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")])
@@ -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
@@ -0,0 +1,174 @@
#include <gtest/gtest.h>
#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<uint16_t> 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
@@ -0,0 +1,430 @@
#include <gtest/gtest.h>
#include <chrono>
#include <thread>
#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<uint16_t> 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
@@ -0,0 +1,3 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml
hoermann_hcp: !include common.yaml
@@ -0,0 +1,3 @@
packages:
modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml
hoermann_hcp: !include common.yaml
@@ -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
+2 -3
View File
@@ -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_;
@@ -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:
@@ -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<MitsubishiCN105::Status &>(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<size_t>{6});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
ctx.select.control(3);
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{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<size_t>{6});
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{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
@@ -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
@@ -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(&reg);
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(&reg);
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<T> 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>([](uint16_t address) -> optional<uint16_t> { return {}; });
server.add_server_register(&reg);
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<bool> { 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
@@ -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
@@ -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)
+7 -3
View File
@@ -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:
+68 -5
View File
@@ -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).
"""
+4 -1
View File
@@ -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
@@ -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
@@ -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