mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
Merge branch 'dev' into rp2-3-connection-slots
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ®isters, uint16_t count) {
|
||||
for (uint16_t i = 0; i < count; i++)
|
||||
registers.push_back(0x0000);
|
||||
}
|
||||
|
||||
// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving.
|
||||
static bool is_moving(DoorState state) {
|
||||
switch (state) {
|
||||
case DoorState::OPENING:
|
||||
case DoorState::CLOSING:
|
||||
case DoorState::MOVE_HALF:
|
||||
case DoorState::MOVE_VENTING:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::update() {
|
||||
const uint32_t now = millis();
|
||||
// Time out the connection flag if the bus controller stopped polling.
|
||||
if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_)
|
||||
this->set_valid_(false);
|
||||
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
|
||||
// otherwise block every later one for as long as it keeps broadcasting.
|
||||
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
|
||||
this->next_command_ = nullptr;
|
||||
this->command_written_at_ = 0;
|
||||
this->clear_target_();
|
||||
}
|
||||
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
|
||||
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
|
||||
if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) {
|
||||
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
|
||||
this->clear_target_();
|
||||
}
|
||||
if (this->changed_) {
|
||||
this->changed_ = false;
|
||||
this->state_callback_.call();
|
||||
}
|
||||
}
|
||||
|
||||
void HoermannHcp::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Hoermann HCP bridge:\n"
|
||||
" Modbus server address: 0x%02X",
|
||||
this->get_address());
|
||||
}
|
||||
|
||||
modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
modbus::RegisterValues ®isters) {
|
||||
if (start_address != STATE_REG) {
|
||||
ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address);
|
||||
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
this->record_response_();
|
||||
|
||||
// 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message
|
||||
// counter (high byte) and command (low byte). The read length identifies which internal block is requested.
|
||||
const uint16_t counter = this->command_reg_value_ & 0xFF00;
|
||||
const uint16_t command = static_cast<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 ®isters) {
|
||||
if (start_address == COMMAND_REG) {
|
||||
// 0x17 write half: stash the command register so the following read half can echo its message counter and
|
||||
// command byte back from STATE_REG. The hub always runs the write before the read within one request.
|
||||
this->record_response_();
|
||||
this->command_reg_value_ = registers[0];
|
||||
return {};
|
||||
}
|
||||
|
||||
if (start_address != BROADCAST_REG) {
|
||||
// Every device sees every broadcast, so a frame meant for another node is ordinary traffic
|
||||
ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address);
|
||||
return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
|
||||
}
|
||||
|
||||
this->record_response_();
|
||||
|
||||
// Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new
|
||||
// position checks the target against the new state.
|
||||
if (registers.size() > 2)
|
||||
this->on_state_reg_(registers[2]);
|
||||
if (registers.size() > 1)
|
||||
this->on_position_reg_(registers[1]);
|
||||
return {};
|
||||
}
|
||||
|
||||
void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) {
|
||||
const HoermannHcpCommand *command = this->next_command_;
|
||||
if (command == nullptr) {
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
if (this->command_written_at_ == 0) {
|
||||
// First read after the command was queued: present the "key pressed" values.
|
||||
this->command_written_at_ = millis();
|
||||
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
|
||||
registers.push_back(command->pressed_value);
|
||||
registers.push_back(0x0000);
|
||||
return;
|
||||
}
|
||||
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
|
||||
// Still inside the key-press window, so keep presenting 0x0000.
|
||||
push_zeros(registers, 2);
|
||||
return;
|
||||
}
|
||||
// Enough time passed: present the "key released" values and clear the command.
|
||||
ESP_LOGD(TAG, "Released '%s' command", command->name);
|
||||
this->command_written_at_ = 0;
|
||||
this->next_command_ = nullptr;
|
||||
registers.push_back(command->released_value);
|
||||
registers.push_back(0x0000);
|
||||
}
|
||||
|
||||
void HoermannHcp::on_position_reg_(uint16_t value) {
|
||||
// Low byte: current position.
|
||||
const uint8_t position = static_cast<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 ®isters) override;
|
||||
modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers,
|
||||
modbus::RegisterValues ®isters) override;
|
||||
|
||||
// Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus
|
||||
// controller cannot be asked right now, so the caller can react.
|
||||
bool open_door();
|
||||
bool close_door();
|
||||
bool impulse_door();
|
||||
bool stop_door();
|
||||
bool set_position(float position);
|
||||
|
||||
DoorState get_door_state() const { return this->door_state_; }
|
||||
float get_current_position() const { return this->current_position_; }
|
||||
bool is_valid() const { return this->valid_; }
|
||||
|
||||
protected:
|
||||
void record_response_();
|
||||
// Returns false when the bus controller has not fetched the previous command yet.
|
||||
bool queue_command_(const HoermannHcpCommand &command);
|
||||
// Appends the two key-press registers and advances the pending command's press/release state.
|
||||
void push_command_registers_(modbus::RegisterValues ®isters);
|
||||
void on_position_reg_(uint16_t value);
|
||||
void on_state_reg_(uint16_t value);
|
||||
|
||||
void set_valid_(bool valid);
|
||||
void set_door_state_(DoorState state);
|
||||
// Recomputes the reported position from position_raw_ and the current door state.
|
||||
void update_current_position_();
|
||||
bool has_target_() const { return this->target_position_ != 0.0f; }
|
||||
void clear_target_();
|
||||
|
||||
CallbackManager<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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
®isters](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 ®isters) final;
|
||||
/// called when a modbus request (function code 0x06 or 0x10) was parsed without errors
|
||||
modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final;
|
||||
/// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are
|
||||
/// served from the same bit table (see ServerBit)
|
||||
modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final;
|
||||
/// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors
|
||||
modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final;
|
||||
/// Called by esphome generated code to set the server courtesy response object
|
||||
void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) {
|
||||
this->server_courtesy_response_ = server_courtesy_response;
|
||||
@@ -118,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice {
|
||||
protected:
|
||||
/// Find the registered value whose register span contains address, or nullptr if none does.
|
||||
ServerRegister *find_containing_register_(uint32_t address) const;
|
||||
/// Find the registered bit at address, or nullptr if none is.
|
||||
ServerBit *find_bit_(uint16_t address) const;
|
||||
/// Collection of all server registers for this component
|
||||
std::vector<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};
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+111
@@ -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(®);
|
||||
|
||||
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION.
|
||||
TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) {
|
||||
ModbusServer server;
|
||||
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
|
||||
}
|
||||
|
||||
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
|
||||
// applied, and the handler reports SERVICE_DEVICE_FAILURE.
|
||||
TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
|
||||
@@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
|
||||
EXPECT_EQ(out[1], 0xABCD);
|
||||
}
|
||||
|
||||
// An unregistered address with courtesy disabled is rejected.
|
||||
// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS.
|
||||
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
|
||||
ModbusServer server;
|
||||
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
|
||||
reg.read_lambda = []() -> int64_t { return 0x1234; };
|
||||
server.add_server_register(®);
|
||||
|
||||
RegisterValues out;
|
||||
auto status = server.on_read_registers(0x0005, 1, out);
|
||||
ASSERT_TRUE(status.has_value());
|
||||
@@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
|
||||
}
|
||||
|
||||
// A server with no registers configured (courtesy disabled) does not implement the register-read
|
||||
// function: ILLEGAL_FUNCTION.
|
||||
TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) {
|
||||
ModbusServer server;
|
||||
RegisterValues out;
|
||||
auto status = server.on_read_registers(0x0005, 1, out);
|
||||
ASSERT_TRUE(status.has_value());
|
||||
if (status.has_value())
|
||||
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION);
|
||||
}
|
||||
|
||||
// A register read lambda returning an empty optional declines the read: the whole request is
|
||||
// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda<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(®);
|
||||
|
||||
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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user