Merge branch 'dev' into enhancement/ethernet-spi-interface

This commit is contained in:
Ross Tyler
2026-04-05 07:37:56 -07:00
committed by GitHub
32 changed files with 503 additions and 52 deletions
+2 -1
View File
@@ -48,7 +48,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.types import ConfigType
from esphome.writer import clean_cmake_cache
from esphome.writer import clean_build, clean_cmake_cache
from .boards import BOARDS, STANDARD_BOARDS
from .const import ( # noqa
@@ -2195,6 +2195,7 @@ def _write_sdkconfig():
if write_file_if_changed(internal_path, contents):
# internal changed, update real one
write_file_if_changed(sdk_path, contents)
clean_build(clear_pio_cache=False)
def _write_idf_component_yml():
+16 -4
View File
@@ -28,7 +28,10 @@ namespace esphome::gpio_expander {
template<typename T, uint16_t N, typename P = typename std::conditional<(N > 256), uint16_t, uint8_t>::type>
class CachedGpioExpander {
public:
/// @brief Read the state of the given pin. This will invalidate the cache for the given pin number.
/// @brief Read the state of the given pin.
/// By default, each read invalidates the pin's cache entry so the next read
/// of the same pin triggers a fresh hardware read. When invalidate_on_read
/// is disabled, the cache stays valid until explicitly cleared via reset_pin_cache_().
/// @param pin Pin number to read
/// @return Pin state
bool digital_read(P pin) {
@@ -36,14 +39,17 @@ class CachedGpioExpander {
const T pin_mask = (1 << (pin % BANK_SIZE));
// Check if specific pin cache is valid
if (this->read_cache_valid_[bank] & pin_mask) {
// Invalidate pin
this->read_cache_valid_[bank] &= ~pin_mask;
if (this->invalidate_on_read_) {
// Invalidate pin so next read triggers hardware read
this->read_cache_valid_[bank] &= ~pin_mask;
}
} else {
// Read whole bank from hardware
if (!this->digital_read_hw(pin))
return false;
// Mark bank cache as valid except the pin that is being returned now
this->read_cache_valid_[bank] = std::numeric_limits<T>::max() & ~pin_mask;
// (when not invalidating on read, mark all pins including this one as valid)
this->read_cache_valid_[bank] = std::numeric_limits<T>::max() & ~(this->invalidate_on_read_ ? pin_mask : 0);
}
return this->digital_read_cache(pin);
}
@@ -71,12 +77,18 @@ class CachedGpioExpander {
/// @brief Invalidate cache. This function should be called in component loop().
void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); }
/// @brief Control whether digital_read() invalidates the pin's cache entry after reading.
/// When enabled (default), each read self-invalidates so the next read triggers a hardware read.
/// When disabled, cache stays valid until reset_pin_cache_() is explicitly called.
void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; }
static constexpr uint16_t BITS_PER_BYTE = 8;
static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE;
static constexpr size_t BANKS = N / BANK_SIZE;
static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T);
T read_cache_valid_[BANKS]{0};
bool invalidate_on_read_{true};
};
} // namespace esphome::gpio_expander
+5 -3
View File
@@ -279,6 +279,10 @@ def _final_validate(config):
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
if config[CONF_BUS_MODE] == TYPE_SINGLE:
spi.final_validate_device_schema(DOMAIN, require_miso=False, require_mosi=True)(
config
)
if not requires_buffer(config) and LVGL_DOMAIN not in global_config:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
@@ -286,7 +290,7 @@ def _final_validate(config):
if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config:
# If PSRAM is not enabled, choose a small buffer size by default
if not requires_buffer(config):
return config # No buffer needed, so no need to set a buffer size
return # No need to pick a size
color_depth = get_color_depth(config)
frac = denominator(config)
width, height, _offset_width, _offset_height = model.get_dimensions(config)
@@ -298,8 +302,6 @@ def _final_validate(config):
x for x in range(2, 17) if fraction >= 1 / x
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -8,6 +8,7 @@ static const char *const TAG = "mitsubishi_cn105.driver";
static constexpr uint32_t WRITE_TIMEOUT_MS = 2000;
static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10;
static constexpr size_t HEADER_LEN = 5;
static constexpr uint8_t PREAMBLE = 0xFC;
static constexpr uint8_t HEADER_BYTE_1 = 0x01;
@@ -15,7 +16,13 @@ static constexpr uint8_t HEADER_BYTE_2 = 0x30;
static constexpr uint8_t PACKET_TYPE_CONNECT_REQUEST = 0x5A;
static constexpr uint8_t PACKET_TYPE_CONNECT_RESPONSE = 0x7A;
static constexpr std::array<uint8_t, 2> CONNECT_REQUEST_PAYLOAD = {{0xCA, 0x01}};
static constexpr std::array<uint8_t, 2> CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01};
static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42;
static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62;
static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02;
static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
static constexpr std::array<uint8_t, 2> STATUS_MSG_TYPES = {STATUS_MSG_SETTINGS, STATUS_MSG_ROOM_TEMP};
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}));
@@ -30,19 +37,29 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static 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); }
void MitsubishiCN105::update() {
bool MitsubishiCN105::update() {
if (const auto start = this->status_update_start_ms_;
start && (get_loop_time_ms() - *start) >= this->update_interval_ms_) {
this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS);
return false;
}
if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) {
this->write_timeout_start_ms_.reset();
this->read_pos_ = 0;
this->set_state_(State::READ_TIMEOUT);
return;
return false;
}
this->read_incoming_bytes_();
return this->read_incoming_bytes_();
}
void MitsubishiCN105::set_state_(State new_state) {
@@ -63,9 +80,24 @@ bool MitsubishiCN105::should_transition(State from, State to) {
return from == State::NOT_CONNECTED || from == State::READ_TIMEOUT;
case State::CONNECTED:
case State::READ_TIMEOUT:
return from == State::CONNECTING;
case State::UPDATING_STATUS:
return from == State::CONNECTED || from == State::STATUS_UPDATED ||
from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
case State::STATUS_UPDATED:
return from == State::UPDATING_STATUS;
case State::SCHEDULE_NEXT_STATUS_UPDATE:
return from == State::STATUS_UPDATED;
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
return from == State::SCHEDULE_NEXT_STATUS_UPDATE;
case State::READ_TIMEOUT:
return from == State::UPDATING_STATUS || from == State::CONNECTING;
default:
return false;
}
@@ -79,7 +111,30 @@ void MitsubishiCN105::did_transition_(State to) {
case State::CONNECTED:
this->write_timeout_start_ms_.reset();
// TODO: read AC status after connected, next PR
this->status_msg_index_ = 0;
this->set_state_(State::UPDATING_STATUS);
break;
case State::UPDATING_STATUS:
this->update_status_();
break;
case State::STATUS_UPDATED: {
this->write_timeout_start_ms_.reset();
if (++this->status_msg_index_ >= STATUS_MSG_TYPES.size()) {
this->status_msg_index_ = 0;
}
if (this->status_msg_index_ != 0) {
this->set_state_(State::UPDATING_STATUS);
} else {
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
}
break;
}
case State::SCHEDULE_NEXT_STATUS_UPDATE:
this->status_update_start_ms_ = get_loop_time_ms();
this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
break;
case State::READ_TIMEOUT:
@@ -97,13 +152,24 @@ void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
this->write_timeout_start_ms_ = get_loop_time_ms();
}
void MitsubishiCN105::read_incoming_bytes_() {
void MitsubishiCN105::update_status_() {
ESP_LOGV(TAG, "Requesting status update, index=%u", this->status_msg_index_);
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {STATUS_MSG_TYPES[this->status_msg_index_]};
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
}
void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) {
this->status_update_start_ms_.reset();
this->set_state_(state);
}
bool MitsubishiCN105::read_incoming_bytes_() {
uint8_t watchdog = 64;
while (this->device_.available() > 0 && watchdog-- > 0) {
uint8_t &value = this->read_buffer_[this->read_pos_];
if (!this->device_.read_byte(&value)) {
ESP_LOGW(TAG, "UART read failed while data available");
return;
return false;
}
switch (++this->read_pos_) {
@@ -149,23 +215,85 @@ void MitsubishiCN105::read_incoming_bytes_() {
continue;
}
this->process_rx_packet_(this->read_buffer_[1], this->read_buffer_ + HEADER_LEN, len_without_checksum - HEADER_LEN);
bool processed = this->process_rx_packet_(this->read_buffer_[1], this->read_buffer_ + HEADER_LEN,
len_without_checksum - HEADER_LEN);
this->reset_read_position_and_dump_buffer_("RX");
return processed;
}
return false;
}
void MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) {
bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) {
switch (type) {
case PACKET_TYPE_CONNECT_RESPONSE:
this->set_state_(State::CONNECTED);
break;
return false;
case PACKET_TYPE_STATUS_RESPONSE:
return this->process_status_packet_(payload, len);
default:
ESP_LOGVV(TAG, "RX unknown packet type 0x%02X", type);
break;
return false;
}
}
bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) {
if (len == 0) {
ESP_LOGVV(TAG, "RX status packet too short");
return false;
}
const auto previous = this->status_;
const auto msg_type = payload[0];
if (!this->parse_status_payload_(msg_type, payload + 1, len - 1)) {
return false;
}
if (msg_type == STATUS_MSG_TYPES[this->status_msg_index_]) {
this->set_state_(State::STATUS_UPDATED);
}
return previous != this->status_ && this->is_status_initialized();
}
bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) {
switch (msg_type) {
case STATUS_MSG_SETTINGS:
return this->parse_status_settings_(payload, len);
case STATUS_MSG_ROOM_TEMP:
return this->parse_status_room_temperature_(payload, len);
default:
ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type);
return false;
}
}
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;
}
this->status_.power_on = payload[2] != 0;
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], 31);
return true;
}
bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) {
if (len <= 5) {
ESP_LOGVV(TAG, "RX room temperature payload too short");
return false;
}
this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10);
return true;
}
void MitsubishiCN105::reset_read_position_and_dump_buffer_(const char *prefix) {
dump_buffer_vv(prefix, this->read_buffer_, this->read_pos_);
this->read_pos_ = 0;
@@ -186,6 +314,14 @@ const LogString *MitsubishiCN105::state_to_string(State state) {
return LOG_STR("Connecting");
case State::CONNECTED:
return LOG_STR("Connected");
case State::UPDATING_STATUS:
return LOG_STR("UpdatingStatus");
case State::STATUS_UPDATED:
return LOG_STR("StatusUpdated");
case State::SCHEDULE_NEXT_STATUS_UPDATE:
return LOG_STR("ScheduleNextStatusUpdate");
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
return LOG_STR("WaitingForScheduledStatusUpdate");
case State::READ_TIMEOUT:
return LOG_STR("ReadTimeout");
}
@@ -9,23 +9,49 @@ uint32_t get_loop_time_ms();
class MitsubishiCN105 {
public:
struct Status {
bool operator==(const Status &) const = default;
bool power_on{false};
float target_temperature{NAN};
float room_temperature{NAN};
};
explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {}
void initialize();
void update();
bool update();
uint32_t get_update_interval() const { return this->update_interval_ms_; }
void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; }
const Status &status() const { return this->status_; }
bool is_status_initialized() const { return !std::isnan(status_.room_temperature); }
protected:
enum class State : uint8_t { NOT_CONNECTED, CONNECTING, CONNECTED, READ_TIMEOUT };
enum class State : uint8_t {
NOT_CONNECTED,
CONNECTING,
CONNECTED,
UPDATING_STATUS,
STATUS_UPDATED,
SCHEDULE_NEXT_STATUS_UPDATE,
WAITING_FOR_SCHEDULED_STATUS_UPDATE,
READ_TIMEOUT
};
void set_state_(State new_state);
void did_transition_(State to);
void read_incoming_bytes_();
void process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len);
bool read_incoming_bytes_();
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_room_temperature_(const uint8_t *payload, size_t len);
void reset_read_position_and_dump_buffer_(const char *prefix);
void send_packet_(const uint8_t *packet, size_t len);
void update_status_();
void cancel_waiting_and_transition_to_(State state);
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);
@@ -34,7 +60,10 @@ class MitsubishiCN105 {
uart::UARTDevice &device_;
uint32_t update_interval_ms_{1000};
std::optional<uint32_t> write_timeout_start_ms_;
std::optional<uint32_t> status_update_start_ms_;
Status status_{};
State state_{State::NOT_CONNECTED};
uint8_t status_msg_index_{0};
private:
static constexpr size_t READ_BUFFER_SIZE = 32;
@@ -17,13 +17,34 @@ void MitsubishiCN105Climate::dump_config() {
void MitsubishiCN105Climate::setup() { this->hp_.initialize(); }
void MitsubishiCN105Climate::loop() { this->hp_.update(); }
void MitsubishiCN105Climate::loop() {
if (this->hp_.update()) {
this->apply_values_();
}
}
climate::ClimateTraits MitsubishiCN105Climate::traits() {
climate::ClimateTraits traits;
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_visual_min_temperature(16.0f);
traits.set_visual_max_temperature(31.0f);
traits.set_visual_temperature_step(1.0f);
traits.set_visual_current_temperature_step(0.5f);
return traits;
}
void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {}
void MitsubishiCN105Climate::apply_values_() {
const auto &status = this->hp_.status();
this->target_temperature = status.target_temperature;
this->current_temperature = status.room_temperature;
this->publish_state();
}
} // namespace esphome::mitsubishi_cn105
@@ -21,6 +21,8 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); }
protected:
void apply_values_();
MitsubishiCN105 hp_;
};
@@ -2,6 +2,6 @@
namespace esphome::mitsubishi_cn105 {
uint32_t __attribute__((weak)) get_loop_time_ms() { return App.get_loop_component_start_time(); };
uint32_t __attribute__((weak)) get_loop_time_ms() { return App.get_loop_component_start_time(); }
} // namespace esphome::mitsubishi_cn105
+4
View File
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT_PIN,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
@@ -29,6 +30,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -43,6 +45,8 @@ async def to_code(config):
cg.add(var.set_pin_count(config[CONF_PIN_COUNT]))
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
def validate_mode(value):
+23 -3
View File
@@ -34,12 +34,26 @@ void PCA9554Component::setup() {
this->read_inputs_();
ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(),
this->status_has_error());
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&PCA9554Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
// Don't invalidate cache on read — only invalidate when interrupt fires
this->set_invalidate_on_read_(false);
}
// Disable loop until an input pin is configured via pin_mode()
// For interrupt-driven mode, loop is re-enabled by the ISR
// For polling mode, loop is re-enabled when pin_mode() registers an input pin
this->disable_loop();
}
void IRAM_ATTR PCA9554Component::gpio_intr(PCA9554Component *arg) { arg->enable_loop_soon_any_context(); }
void PCA9554Component::loop() {
// Invalidate the cache at the start of each loop.
// The actual read will happen on demand when digital_read() is called
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Interrupt-driven: disable loop until next interrupt fires
this->disable_loop();
}
}
void PCA9554Component::dump_config() {
@@ -47,6 +61,7 @@ void PCA9554Component::dump_config() {
"PCA9554:\n"
" I/O Pins: %d",
this->pin_count_);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this)
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
@@ -76,6 +91,11 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) {
if (flags == gpio::FLAG_INPUT) {
// Clear mode mask bit
this->config_mask_ &= ~(1 << pin);
// Enable polling loop for input pins (not needed for interrupt-driven mode
// where the ISR handles re-enabling loop)
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == gpio::FLAG_OUTPUT) {
// Set mode mask bit
this->config_mask_ |= 1 << pin;
+4 -1
View File
@@ -16,7 +16,6 @@ class PCA9554Component : public Component,
/// Check i2c availability and setup masks
void setup() override;
/// Invalidate cache at start of each loop
void loop() override;
/// Helper function to set the pin mode of a pin.
void pin_mode(uint8_t pin, gpio::Flags flags);
@@ -26,8 +25,11 @@ class PCA9554Component : public Component,
void dump_config() override;
void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; }
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
protected:
static void IRAM_ATTR gpio_intr(PCA9554Component *arg);
bool read_inputs_();
bool write_register_(uint8_t reg, uint16_t value);
@@ -48,6 +50,7 @@ class PCA9554Component : public Component,
uint16_t input_mask_{0x00};
/// Storage for last I2C error seen
esphome::i2c::ErrorCode last_error_;
InternalGPIOPin *interrupt_pin_{nullptr};
};
/// Helper class to expose a PCA9554 pin as an internal input GPIO pin.
+4
View File
@@ -5,6 +5,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT_PIN,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
@@ -27,6 +28,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
cv.Optional(CONF_PCF8575, default=False): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -39,6 +41,8 @@ async def to_code(config):
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_pcf8575(config[CONF_PCF8575]))
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
def validate_mode(value):
+23 -1
View File
@@ -15,16 +15,33 @@ void PCF8574Component::setup() {
this->write_gpio_();
this->read_gpio_();
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->interrupt_pin_->attach_interrupt(&PCF8574Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
// Don't invalidate cache on read — only invalidate when interrupt fires
this->set_invalidate_on_read_(false);
}
// Disable loop until an input pin is configured via pin_mode()
// For interrupt-driven mode, loop is re-enabled by the ISR
// For polling mode, loop is re-enabled when pin_mode() registers an input pin
this->disable_loop();
}
void IRAM_ATTR PCF8574Component::gpio_intr(PCF8574Component *arg) { arg->enable_loop_soon_any_context(); }
void PCF8574Component::loop() {
// Invalidate the cache at the start of each loop
// Invalidate the cache so the next digital_read() triggers a fresh I2C read
this->reset_pin_cache_();
if (this->interrupt_pin_ != nullptr) {
// Interrupt-driven: disable loop until next interrupt fires
this->disable_loop();
}
}
void PCF8574Component::dump_config() {
ESP_LOGCONFIG(TAG,
"PCF8574:\n"
" Is PCF8575: %s",
YESNO(this->pcf8575_));
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this)
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
@@ -51,6 +68,11 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) {
this->mode_mask_ &= ~(1 << pin);
// Write GPIO to enable input mode
this->write_gpio_();
// Enable polling loop for input pins (not needed for interrupt-driven mode
// where the ISR handles re-enabling loop)
if (this->interrupt_pin_ == nullptr) {
this->enable_loop();
}
} else if (flags == gpio::FLAG_OUTPUT) {
// Set mode mask bit
this->mode_mask_ |= 1 << pin;
+4 -1
View File
@@ -17,10 +17,10 @@ class PCF8574Component : public Component,
PCF8574Component() = default;
void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; }
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
/// Check i2c availability and setup masks
void setup() override;
/// Invalidate cache at start of each loop
void loop() override;
/// Helper function to set the pin mode of a pin.
void pin_mode(uint8_t pin, gpio::Flags flags);
@@ -30,6 +30,8 @@ class PCF8574Component : public Component,
void dump_config() override;
protected:
static void IRAM_ATTR gpio_intr(PCF8574Component *arg);
bool digital_read_hw(uint8_t pin) override;
bool digital_read_cache(uint8_t pin) override;
void digital_write_hw(uint8_t pin, bool value) override;
@@ -44,6 +46,7 @@ class PCF8574Component : public Component,
/// The state read in read_gpio_ - 1 means HIGH, 0 means LOW
uint16_t input_mask_{0x00};
bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574
InternalGPIOPin *interrupt_pin_{nullptr};
};
/// Helper class to expose a PCF8574 pin as an internal input GPIO pin.
+9 -1
View File
@@ -68,7 +68,7 @@ void SPIComponent::dump_config() {
LOG_PIN(" SDI Pin: ", this->sdi_pin_);
LOG_PIN(" SDO Pin: ", this->sdo_pin_);
for (size_t i = 0; i != this->data_pins_.size(); i++) {
ESP_LOGCONFIG(TAG, " Data pin %u: GPIO%d", i, this->data_pins_[i]);
ESP_LOGCONFIG(TAG, " Data pin %zu: GPIO%d", i, this->data_pins_[i]);
}
if (this->spi_bus_->is_hw()) {
ESP_LOGCONFIG(TAG, " Using HW SPI: %s", this->interface_name_);
@@ -118,4 +118,12 @@ uint16_t SPIDelegateBitBash::transfer_(uint16_t data, size_t num_bits) {
return out_data;
}
#if !defined(USE_ESP32) && !defined(USE_ARDUINO)
// Stub for unsupported platforms (host, Zephyr, etc.) - hardware SPI is unavailable
SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo, GPIOPin *sdi,
const std::vector<uint8_t> &data_pins) {
return nullptr;
}
#endif
} // namespace esphome::spi
+2 -2
View File
@@ -23,9 +23,9 @@ using SPIInterface = SPIClassRP2040 *;
using SPIInterface = SPIClass *;
#endif
#elif defined(CLANG_TIDY)
#elif defined(USE_HOST) || defined(CLANG_TIDY)
using SPIInterface = void *; // Stub for platforms without SPI (e.g., Zephyr)
using SPIInterface = void *; // Stub for platforms without SPI (e.g., host, Zephyr)
#endif // USE_ESP32 / USE_ARDUINO
+6 -1
View File
@@ -20,7 +20,12 @@ bool CronTrigger::matches(const ESPTime &time) {
return time.is_valid() && this->seconds_[time.second] && this->minutes_[time.minute] && this->hours_[time.hour] &&
this->days_of_month_[time.day_of_month] && this->months_[time.month] && this->days_of_week_[time.day_of_week];
}
void CronTrigger::loop() {
void CronTrigger::setup() {
// Cron resolution is 1 second — check once per second instead of every loop iteration
this->set_interval(1000, [this]() { this->check_time_(); });
}
void CronTrigger::check_time_() {
ESPTime time = this->rtc_->now();
if (!time.is_valid())
return;
+2 -1
View File
@@ -26,10 +26,11 @@ class CronTrigger : public Trigger<>, public Component {
void add_day_of_week(uint8_t day_of_week);
void add_days_of_week(const std::vector<uint8_t> &days_of_week);
bool matches(const ESPTime &time);
void loop() override;
void setup() override;
float get_setup_priority() const override;
protected:
void check_time_();
std::bitset<61> seconds_;
std::bitset<60> minutes_;
std::bitset<24> hours_;
@@ -1,6 +1,7 @@
"""Tests for mpip_spi configuration validation."""
from collections.abc import Callable, Generator
from unittest import mock
import pytest
@@ -12,6 +13,16 @@ from esphome.core import CORE
from esphome.pins import gpio_pin_schema
@pytest.fixture(autouse=True)
def mock_spi_final_validate():
"""Mock spi.final_validate_device_schema since unit tests have no real SPI bus config."""
with mock.patch(
"esphome.components.spi.final_validate_device_schema",
return_value=lambda config: None,
):
yield
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""
@@ -25,7 +25,9 @@ from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config):
"""Run schema + final validation and return the validated config."""
return FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
def test_metadata_native_quad_default_test_card(
@@ -25,27 +25,80 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) {
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{123});
}
TEST(MitsubishiCN105Tests, SuccessfullyConnects) {
TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
auto ctx = TestContext{};
ctx.sut.initialize();
ctx.uart.tx.clear(); // Remove first connect packet bytes
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.sut.write_timeout_start_ms_.has_value());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Connect response
ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55});
ctx.sut.update();
ctx.sut.set_current_time(200);
ASSERT_FALSE(ctx.sut.update());
// All bytes from UART should be consumed and state = CONNECTED
// All bytes from UART should be consumed
EXPECT_TRUE(ctx.uart.rx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTED);
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
// After successful connect we request status, first settings (0x02)
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{200});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Clear TX bytes.
ctx.uart.tx.clear();
// Settings response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
0x00, 0x00, 0x00, 0x00, 0x03, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x99});
// Settings should still have initial values
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_THAT(ctx.sut.status().target_temperature, ::testing::IsNan());
ctx.sut.set_current_time(300);
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.rx.empty());
// Check settings that we just read from received package
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_EQ(ctx.sut.status().target_temperature, 24.0f);
// Now fetch room temperature (0x03)
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{300});
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Clear TX bytes.
ctx.uart.tx.clear();
// Room temperature response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
// Room temperature should still have initial value
EXPECT_THAT(ctx.sut.status().room_temperature, ::testing::IsNan());
ctx.sut.set_current_time(400);
EXPECT_FALSE(ctx.sut.is_status_initialized());
ASSERT_TRUE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.rx.empty());
EXPECT_TRUE(ctx.sut.is_status_initialized());
// Check room temperature we just read from received package
EXPECT_EQ(ctx.sut.status().room_temperature, 21.0f);
// Nothing should be send to UART
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value());
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{400});
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
@@ -55,21 +108,21 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
ctx.uart.tx.clear(); // Remove first connect packet bytes
// No response (no RX data), no retry yet
ctx.sut.update();
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
// Still no response after 1999ms, no retry yet
ctx.sut.set_current_time(1999);
ctx.sut.update();
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{0});
// Stop waiting after 2s and retry connect
ctx.sut.set_current_time(2000);
ctx.sut.update();
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8));
EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional<uint32_t>{2000});
@@ -92,7 +145,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
ASSERT_GT(ctx.uart.rx.size(), 64);
// No valid response, no state change expected
ctx.sut.update();
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
@@ -100,7 +153,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) {
EXPECT_FALSE(ctx.uart.rx.empty());
// Next update will read remaining bytes, no state change expected
ctx.sut.update();
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_TRUE(ctx.uart.rx.empty());
@@ -162,7 +215,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) {
// Drain RX - no valid response, no state change expected
int iterations = 0;
while (!ctx.uart.rx.empty() && iterations++ < 10) {
ctx.sut.update();
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING);
EXPECT_TRUE(ctx.uart.tx.empty());
}
@@ -170,4 +223,81 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) {
EXPECT_TRUE(ctx.uart.rx.empty());
}
TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) {
auto ctx = TestContext{};
ctx.sut.set_update_interval(2000);
ctx.sut.set_current_time(80000);
// No scheduled status update
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
// Status update completed, schedule next status update
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{80000});
// Wait for update_interval (ms) before doing another status update
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(81999);
ASSERT_FALSE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(82000);
ASSERT_FALSE(ctx.sut.update());
EXPECT_FALSE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) {
auto ctx = TestContext{};
ctx.uart.push_rx(
{0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56});
ctx.sut.update();
EXPECT_TRUE(ctx.sut.status().power_on);
EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f);
}
TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) {
auto ctx = TestContext{};
ctx.uart.push_rx(
{0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xB7});
ctx.sut.update();
EXPECT_FALSE(ctx.sut.status().power_on);
EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f);
}
TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) {
auto ctx = TestContext{};
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D});
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().room_temperature, 16.0f);
}
TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) {
auto ctx = TestContext{};
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7});
ctx.sut.update();
EXPECT_EQ(ctx.sut.status().room_temperature, 30.0f);
}
} // namespace esphome::mitsubishi_cn105::testing
+1 -1
View File
@@ -2,6 +2,6 @@
namespace esphome::mitsubishi_cn105 {
uint32_t get_loop_time_ms() { return testing::TestableMitsubishiCN105::test_loop_time_ms; };
uint32_t get_loop_time_ms() { return testing::TestableMitsubishiCN105::test_loop_time_ms; }
} // namespace esphome::mitsubishi_cn105
@@ -44,6 +44,9 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
using MitsubishiCN105::State;
using MitsubishiCN105::state_;
using MitsubishiCN105::write_timeout_start_ms_;
using MitsubishiCN105::status_update_start_ms_;
void set_state(State s) { this->set_state_(s); }
static inline uint32_t test_loop_time_ms = 0;
+5
View File
@@ -3,6 +3,11 @@ pca9554:
i2c_id: i2c_bus
pin_count: 8
address: 0x3F
- id: pca9554_hub_int
i2c_id: i2c_bus
pin_count: 8
address: 0x3E
interrupt_pin: ${interrupt_pin}
binary_sensor:
- platform: gpio
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
+5
View File
@@ -3,6 +3,11 @@ pcf8574:
i2c_id: i2c_bus
address: 0x21
pcf8575: false
- id: pcf8574_hub_int
i2c_id: i2c_bus
address: 0x22
pcf8575: false
interrupt_pin: ${interrupt_pin}
binary_sensor:
- platform: gpio
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
@@ -1,3 +1,6 @@
substitutions:
interrupt_pin: GPIO2
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
+4
View File
@@ -6,5 +6,9 @@ api:
time:
- platform: homeassistant
on_time:
- seconds: "0,10,20,30,40,50"
then:
- logger.log: "CronTrigger fired (every 10 seconds)"
- platform: sntp
id: sntp_time