mirror of
https://github.com/esphome/esphome.git
synced 2026-08-29 01:03:29 +00:00
Merge remote-tracking branch 'origin/dev' into esp8266-native-library-backend
This commit is contained in:
@@ -577,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim
|
||||
esphome/components/tuya/sensor/* @jesserockz
|
||||
esphome/components/tuya/switch/* @jesserockz
|
||||
esphome/components/tuya/text_sensor/* @dentra
|
||||
esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "esphome/components/wifi/scan_list.h"
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
#include "captive_index.h"
|
||||
|
||||
namespace esphome::captive_portal {
|
||||
@@ -78,6 +81,20 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
|
||||
void CaptivePortal::setup() {
|
||||
// Disable loop by default - will be enabled when captive portal starts
|
||||
this->disable_loop();
|
||||
#ifdef USE_PROVISIONING
|
||||
// The captive portal is a provisioning surface: once the provisioning window
|
||||
// has closed, stop serving it. WiFi's own closed-callback shuts down the
|
||||
// access point the portal runs on, and the gated fallback in WiFiComponent's
|
||||
// loop() ensures neither is started again afterwards.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
if (this->active_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
|
||||
this->end();
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
}
|
||||
void CaptivePortal::start() {
|
||||
this->base_->init();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "esp32_improv_component.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "esphome/components/bytebuffer/bytebuffer.h"
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
#include "esphome/components/esp32_ble_server/ble_2902.h"
|
||||
@@ -19,7 +21,13 @@ using namespace bytebuffer;
|
||||
|
||||
static const char *const TAG = "esp32_improv.component";
|
||||
static constexpr size_t IMPROV_MAX_LOG_BYTES = 128;
|
||||
static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
|
||||
static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
|
||||
// command + data length + trailing byte
|
||||
static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
|
||||
// Reserves the ESPHOME_MY_LINK entry; a maximal next URL displaces only the
|
||||
// lower value web server URL
|
||||
static constexpr size_t MAX_NEXT_URL_LEN =
|
||||
improv::RPC_RESPONSE_MAX_SIZE - RPC_RESPONSE_OVERHEAD - 1 - sizeof(ESPHOME_MY_LINK);
|
||||
static constexpr uint16_t STOP_ADVERTISING_DELAY =
|
||||
10000; // Delay (ms) before stopping service to allow BLE clients to read the final state
|
||||
static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds
|
||||
@@ -285,8 +293,9 @@ void ESP32ImprovComponent::set_error_(improv::Error error) {
|
||||
}
|
||||
}
|
||||
|
||||
void ESP32ImprovComponent::send_response_(std::vector<uint8_t> &&response) {
|
||||
this->rpc_response_->set_value(std::move(response));
|
||||
void ESP32ImprovComponent::send_response_(std::span<const uint8_t> response) {
|
||||
// The BLE characteristic owns its value, so one exact-size copy is required here
|
||||
this->rpc_response_->set_value(std::vector<uint8_t>(response.begin(), response.end()));
|
||||
if (this->state_ != improv::STATE_STOPPED)
|
||||
this->rpc_response_->notify();
|
||||
}
|
||||
@@ -430,40 +439,35 @@ void ESP32ImprovComponent::check_wifi_connection_() {
|
||||
this->connecting_sta_ = {};
|
||||
this->cancel_timeout("wifi-connect-timeout");
|
||||
|
||||
// Build URL list with minimal allocations
|
||||
// Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL
|
||||
std::string url_strings[3];
|
||||
size_t url_count = 0;
|
||||
// Build the URL list directly into a stack buffer with no heap allocation
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
|
||||
|
||||
#ifdef USE_ESP32_IMPROV_NEXT_URL
|
||||
// Add next_url if configured (should be first per Improv BLE spec)
|
||||
{
|
||||
char url_buffer[384];
|
||||
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
|
||||
if (len > 0) {
|
||||
url_strings[url_count++] = std::string(url_buffer, len);
|
||||
}
|
||||
}
|
||||
this->add_next_url_(builder, MAX_NEXT_URL_LEN);
|
||||
#endif
|
||||
|
||||
// Add default URLs for backward compatibility
|
||||
url_strings[url_count++] = ESPHOME_MY_LINK;
|
||||
// Add default URLs for backward compatibility; MAX_NEXT_URL_LEN reserves this
|
||||
// entry's space, so it always fits
|
||||
builder.add_string(ESPHOME_MY_LINK, sizeof(ESPHOME_MY_LINK) - 1);
|
||||
#ifdef USE_WEBSERVER
|
||||
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
|
||||
if (ip.is_ip4()) {
|
||||
// "http://" (7) + IPv4 max (15) + ":" (1) + port max (5) + null = 29
|
||||
char url_buffer[32];
|
||||
memcpy(url_buffer, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
|
||||
ip.str_to(url_buffer + 7);
|
||||
size_t len = strlen(url_buffer);
|
||||
snprintf(url_buffer + len, sizeof(url_buffer) - len, ":%d", USE_WEBSERVER_PORT);
|
||||
url_strings[url_count++] = url_buffer;
|
||||
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||
ip.str_to(ip_buf);
|
||||
// "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
|
||||
char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
|
||||
size_t len =
|
||||
buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
|
||||
if (!builder.add_string(webserver_url, len)) {
|
||||
ESP_LOGW(TAG, "Response full; URL dropped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS,
|
||||
std::vector<std::string>(url_strings, url_strings + url_count)));
|
||||
this->send_response_(builder.finish());
|
||||
} else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
|
||||
ESP_LOGD(TAG, "WiFi provisioned externally");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "esphome/components/output/binary_output.h"
|
||||
#endif
|
||||
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
@@ -109,7 +110,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB
|
||||
void set_state_(improv::State state, bool update_advertising = true);
|
||||
void set_error_(improv::Error error);
|
||||
improv::State get_initial_state_() const;
|
||||
void send_response_(std::vector<uint8_t> &&response);
|
||||
void send_response_(std::span<const uint8_t> response);
|
||||
void process_incoming_data_();
|
||||
void on_wifi_connect_timeout_();
|
||||
void check_wifi_connection_();
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::improv_base {
|
||||
|
||||
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
|
||||
static const char *const TAG = "improv_base";
|
||||
|
||||
static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}";
|
||||
static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1;
|
||||
static constexpr const char IP_ADDRESS_PLACEHOLDER[] = "{{ip_address}}";
|
||||
@@ -62,6 +65,21 @@ size_t ImprovBase::get_formatted_next_url_(char *buffer, size_t buffer_size) {
|
||||
*out = '\0';
|
||||
return out - buffer;
|
||||
}
|
||||
|
||||
void ImprovBase::add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len) {
|
||||
// The builder rejects strings above 254 bytes, so anything longer than this
|
||||
// buffer could never be sent anyway
|
||||
char url_buffer[256];
|
||||
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
// max_len is the transport's budget for this entry; skipping an over-long URL
|
||||
// here keeps the rest of the response sendable instead of oversizing the frame
|
||||
if (len > max_len || !builder.add_string(url_buffer, len)) {
|
||||
ESP_LOGW(TAG, "Next URL too long; skipping");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::improv_base
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <cstddef>
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
|
||||
#include <improv.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::improv_base {
|
||||
|
||||
class ImprovBase {
|
||||
@@ -15,6 +19,8 @@ class ImprovBase {
|
||||
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
|
||||
/// Format next_url_ into buffer, replacing placeholders. Returns length written.
|
||||
size_t get_formatted_next_url_(char *buffer, size_t buffer_size);
|
||||
/// Append the formatted next_url to the RPC response, warning if it does not fit.
|
||||
void add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len);
|
||||
const char *next_url_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#include "esphome/components/wifi/scan_list.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::improv_serial {
|
||||
|
||||
static const char *const TAG = "improv_serial";
|
||||
@@ -61,8 +63,7 @@ void ImprovSerialComponent::loop() {
|
||||
this->cancel_timeout("wifi-connect-timeout");
|
||||
this->set_state_(improv::STATE_PROVISIONED);
|
||||
|
||||
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS);
|
||||
this->send_response_(url);
|
||||
this->send_settings_response_(improv::WIFI_SETTINGS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,16 +143,11 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
|
||||
#endif
|
||||
}
|
||||
|
||||
std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) {
|
||||
std::vector<std::string> urls;
|
||||
void ImprovSerialComponent::send_settings_response_(improv::Command command) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, command);
|
||||
#ifdef USE_IMPROV_SERIAL_NEXT_URL
|
||||
{
|
||||
char url_buffer[384];
|
||||
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
|
||||
if (len > 0) {
|
||||
urls.emplace_back(url_buffer, len);
|
||||
}
|
||||
}
|
||||
this->add_next_url_(builder, MAX_NEXT_URL_LEN);
|
||||
#endif
|
||||
#ifdef USE_WEBSERVER
|
||||
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
|
||||
@@ -160,25 +156,63 @@ std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv:
|
||||
ip.str_to(ip_buf);
|
||||
// "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
|
||||
char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
|
||||
snprintf(webserver_url, sizeof(webserver_url), "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
|
||||
urls.emplace_back(webserver_url);
|
||||
// buf_append_printf keeps the format string in flash on ESP8266
|
||||
size_t len =
|
||||
buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
|
||||
if (!builder.add_string(webserver_url, len)) {
|
||||
ESP_LOGW(TAG, "Response full; URL dropped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
std::vector<uint8_t> data = improv::build_rpc_response(command, urls, false);
|
||||
return data;
|
||||
this->send_response_(builder.finish(false));
|
||||
}
|
||||
|
||||
std::vector<uint8_t> ImprovSerialComponent::build_version_info_() {
|
||||
void ImprovSerialComponent::send_version_info_() {
|
||||
// Entry cost per field is sizeof(lit): a length byte plus the string
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
std::vector<std::string> infos = {ESPHOME_PROJECT_NAME, ESPHOME_PROJECT_VERSION, ESPHOME_VARIANT, App.get_name()};
|
||||
static constexpr size_t INFO_ENTRIES_LEN =
|
||||
sizeof(ESPHOME_PROJECT_NAME) + sizeof(ESPHOME_PROJECT_VERSION) + sizeof(ESPHOME_VARIANT);
|
||||
#else
|
||||
std::vector<std::string> infos = {"ESPHome", ESPHOME_VERSION, ESPHOME_VARIANT, App.get_name()};
|
||||
static constexpr size_t INFO_ENTRIES_LEN = sizeof("ESPHome") + sizeof(ESPHOME_VERSION) + sizeof(ESPHOME_VARIANT);
|
||||
#endif
|
||||
std::vector<uint8_t> data = improv::build_rpc_response(improv::GET_DEVICE_INFO, infos, false);
|
||||
return data;
|
||||
};
|
||||
static_assert(INFO_ENTRIES_LEN < MAX_SERIAL_PAYLOAD,
|
||||
"esphome project name and version too long for the improv_serial device info frame");
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
|
||||
#ifdef USE_ESP8266
|
||||
// Keep each literal in flash and copy it through an exact size stack buffer,
|
||||
// so a long project name or version can never be truncated
|
||||
#define IMPROV_ADD_INFO(lit) \
|
||||
do { \
|
||||
static const char progmem_str[] PROGMEM = lit; \
|
||||
char tmp[sizeof(lit)]; \
|
||||
progmem_memcpy(tmp, progmem_str, sizeof(lit)); \
|
||||
builder.add_string(tmp, sizeof(lit) - 1); \
|
||||
} while (0)
|
||||
#else
|
||||
// Literals are directly flash mapped on all other platforms
|
||||
#define IMPROV_ADD_INFO(lit) builder.add_string(lit, sizeof(lit) - 1)
|
||||
#endif
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
IMPROV_ADD_INFO(ESPHOME_PROJECT_NAME);
|
||||
IMPROV_ADD_INFO(ESPHOME_PROJECT_VERSION);
|
||||
#else
|
||||
IMPROV_ADD_INFO("ESPHome");
|
||||
IMPROV_ADD_INFO(ESPHOME_VERSION);
|
||||
#endif
|
||||
IMPROV_ADD_INFO(ESPHOME_VARIANT);
|
||||
#undef IMPROV_ADD_INFO
|
||||
// Only the device name length is unknown at compile time
|
||||
const auto &name = App.get_name();
|
||||
if (INFO_ENTRIES_LEN + 1 + name.size() <= MAX_SERIAL_PAYLOAD) {
|
||||
builder.add_string(name.c_str(), name.size());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Response full; device name dropped");
|
||||
}
|
||||
this->send_response_(builder.finish(false));
|
||||
}
|
||||
|
||||
bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) {
|
||||
size_t at = this->rx_buffer_.size();
|
||||
@@ -229,32 +263,35 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
|
||||
}
|
||||
this->set_state_(this->state_);
|
||||
if (this->state_ == improv::STATE_PROVISIONED) {
|
||||
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE);
|
||||
this->send_response_(url);
|
||||
this->send_settings_response_(improv::GET_CURRENT_STATE);
|
||||
}
|
||||
return true;
|
||||
case improv::GET_DEVICE_INFO: {
|
||||
std::vector<uint8_t> info = this->build_version_info_();
|
||||
this->send_response_(info);
|
||||
this->send_version_info_();
|
||||
return true;
|
||||
}
|
||||
case improv::GET_WIFI_NETWORKS: {
|
||||
const auto &results = wifi::global_wifi_component->get_scan_result();
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
for (const auto &scan : results) {
|
||||
bool with_auth = false;
|
||||
if (!wifi::should_show_scan_entry(results, scan, with_auth))
|
||||
continue;
|
||||
// Send each ssid separately to avoid overflowing the buffer
|
||||
char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null
|
||||
*int8_to_str(rssi_buf, scan.get_rssi()) = '\0';
|
||||
std::vector<uint8_t> data = improv::build_rpc_response(
|
||||
improv::GET_WIFI_NETWORKS, {scan.get_ssid().str(), rssi_buf, YESNO(with_auth)}, false);
|
||||
this->send_response_(data);
|
||||
char *rssi_end = int8_to_str(rssi_buf, scan.get_rssi());
|
||||
*rssi_end = '\0';
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
|
||||
// SSID(32) + RSSI(4) + YESNO(3) entries always fit the payload
|
||||
const auto &ssid = scan.get_ssid();
|
||||
builder.add_string(ssid.c_str(), ssid.size());
|
||||
builder.add_string(rssi_buf, rssi_end - rssi_buf);
|
||||
builder.add_string(YESNO(with_auth));
|
||||
this->send_response_(builder.finish(false));
|
||||
}
|
||||
// Send empty response to signify the end of the list.
|
||||
std::vector<uint8_t> data =
|
||||
improv::build_rpc_response(improv::GET_WIFI_NETWORKS, std::vector<std::string>{}, false);
|
||||
this->send_response_(data);
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
|
||||
this->send_response_(builder.finish(false));
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
@@ -282,7 +319,14 @@ void ImprovSerialComponent::set_error_(improv::Error error) {
|
||||
this->write_data_();
|
||||
}
|
||||
|
||||
void ImprovSerialComponent::send_response_(std::vector<uint8_t> &response) {
|
||||
void ImprovSerialComponent::send_response_(std::span<const uint8_t> response) {
|
||||
// The serial frame length field is a single byte
|
||||
if (response.size() > MAX_SERIAL_RESPONSE) {
|
||||
ESP_LOGE(TAG, "Response too long");
|
||||
// Fail fast instead of leaving the client to wait out its timeout
|
||||
this->set_error_(improv::ERROR_UNKNOWN);
|
||||
return;
|
||||
}
|
||||
this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE;
|
||||
this->write_data_(response.data(), response.size());
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_WIFI
|
||||
#include <improv.h>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_IMPROV_SERIAL_UART
|
||||
@@ -47,6 +48,22 @@ enum ImprovSerialType : uint8_t {
|
||||
static const uint16_t IMPROV_SERIAL_TIMEOUT = 100;
|
||||
static const uint8_t IMPROV_SERIAL_VERSION = 1;
|
||||
|
||||
// The serial frame length field is one byte
|
||||
static constexpr size_t MAX_SERIAL_RESPONSE = 255;
|
||||
// command + data length + trailing byte
|
||||
static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
|
||||
static constexpr size_t MAX_SERIAL_PAYLOAD = MAX_SERIAL_RESPONSE - RPC_RESPONSE_OVERHEAD;
|
||||
#ifdef USE_WEBSERVER
|
||||
// length byte + "http://" + IPv4 + ":" + port
|
||||
static constexpr size_t WEBSERVER_URL_RESERVE = 1 + 7 + 15 + 1 + 5;
|
||||
#else
|
||||
static constexpr size_t WEBSERVER_URL_RESERVE = 0;
|
||||
#endif
|
||||
// Entry budget minus its own length byte
|
||||
static constexpr size_t MAX_NEXT_URL_LEN = MAX_SERIAL_PAYLOAD - WEBSERVER_URL_RESERVE - 1;
|
||||
|
||||
static_assert(MAX_SERIAL_RESPONSE <= improv::RPC_RESPONSE_MAX_SIZE, "builder buffer too small for the frame");
|
||||
|
||||
class ImprovSerialComponent final : public Component, public improv_base::ImprovBase {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -66,11 +83,11 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
|
||||
void set_state_(improv::State state);
|
||||
void send_current_state_(improv::State state);
|
||||
void set_error_(improv::Error error);
|
||||
void send_response_(std::vector<uint8_t> &response);
|
||||
void send_response_(std::span<const uint8_t> response);
|
||||
void on_wifi_connect_timeout_();
|
||||
|
||||
std::vector<uint8_t> build_rpc_settings_response_(improv::Command command);
|
||||
std::vector<uint8_t> build_version_info_();
|
||||
void send_settings_response_(improv::Command command);
|
||||
void send_version_info_();
|
||||
|
||||
ESPHOME_ALWAYS_INLINE optional<uint8_t> read_byte_() {
|
||||
optional<uint8_t> byte;
|
||||
|
||||
@@ -278,6 +278,21 @@ def _final_validate(config: ConfigType) -> None:
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def reject_odd_holding_write_offset(config: ConfigType) -> ConfigType:
|
||||
"""Reject an odd byte offset on a holding-register write entity.
|
||||
|
||||
A 16-bit register write cannot target half a register, so the residual byte is inexpressible.
|
||||
"""
|
||||
key = CONF_BYTE_OFFSET if CONF_BYTE_OFFSET in config else CONF_OFFSET
|
||||
if config.get(key, 0) % 2:
|
||||
raise cv.Invalid(
|
||||
f"An odd '{key}' cannot be used with holding-register writes: a 16-bit register "
|
||||
"write cannot target half a register. Use an even offset, or fold it into 'address'",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
|
||||
byte_offset = 0
|
||||
reg_count = 0
|
||||
|
||||
@@ -14,6 +14,7 @@ from .. import (
|
||||
SensorItem,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
)
|
||||
from ..const import (
|
||||
CONF_CUSTOM_COMMAND,
|
||||
@@ -53,23 +54,26 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
"holding": output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
|
||||
cv.Required(CONF_ADDRESS): cv.positive_int,
|
||||
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
|
||||
"custom_pdu is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
|
||||
"custom_command is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
|
||||
SENSOR_VALUE_TYPE
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
}
|
||||
"holding": cv.All(
|
||||
output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
|
||||
cv.Required(CONF_ADDRESS): cv.positive_int,
|
||||
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
|
||||
"custom_pdu is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
|
||||
"custom_command is not supported for outputs; use a write_lambda instead"
|
||||
),
|
||||
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
|
||||
SENSOR_VALUE_TYPE
|
||||
),
|
||||
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
|
||||
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
|
||||
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
reject_odd_holding_write_offset,
|
||||
),
|
||||
},
|
||||
lower=True,
|
||||
|
||||
@@ -12,7 +12,8 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
|
||||
public:
|
||||
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
|
||||
this->register_type = modbus::EntityType::HOLDING;
|
||||
this->set_address(start_address + offset);
|
||||
// A byte offset folds into the address as whole registers; odd offsets are rejected at validation.
|
||||
this->set_address(start_address + offset / 2);
|
||||
this->set_offset_from_start_address(0);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->register_count = register_count;
|
||||
|
||||
@@ -11,6 +11,7 @@ from .. import (
|
||||
add_modbus_base_properties,
|
||||
modbus_calc_properties,
|
||||
modbus_controller_ns,
|
||||
reject_odd_holding_write_offset,
|
||||
validate_custom_pdu_item,
|
||||
validate_modbus_register,
|
||||
)
|
||||
@@ -31,6 +32,14 @@ ModbusSwitch = modbus_controller_ns.class_(
|
||||
"ModbusSwitch", cg.Component, switch.Switch, SensorItem
|
||||
)
|
||||
|
||||
|
||||
def _validate_holding_offset(config: ConfigType) -> ConfigType:
|
||||
# Only a holding-register switch folds the byte offset into a 16-bit register write.
|
||||
if config.get(CONF_REGISTER_TYPE) == "holding":
|
||||
reject_odd_holding_write_offset(config)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
switch.switch_schema(ModbusSwitch, default_restore_mode="DISABLED")
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -44,6 +53,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
),
|
||||
validate_modbus_register,
|
||||
_validate_holding_offset,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
|
||||
@@ -65,6 +65,7 @@ void ModbusSwitch::write_state(bool state) {
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::helpers::PduBuffer data;
|
||||
bool write_value = state;
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the written value (return a
|
||||
// value), or (deprecated) fill `data` with a custom PDU.
|
||||
@@ -92,26 +93,29 @@ void ModbusSwitch::write_state(bool state) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
// The returned bool is the wire value only; the entity still reports the requested state. A polled
|
||||
// entity needs the read lambda inverted to match, or the next poll flips the display back.
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
write_value = val.value();
|
||||
}
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
|
||||
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s (wire = %s) type = %d address = %X offset = %x",
|
||||
this->get_name().c_str(), ONOFF(state), ONOFF(write_value), (int) this->register_type, this->start_address,
|
||||
this->offset);
|
||||
bool queued;
|
||||
if (this->register_type == EntityType::COIL) {
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<bool, 1> states{state};
|
||||
std::array<bool, 1> states{write_value};
|
||||
queued = this->write_multiple_coils(this->write_address(), states);
|
||||
} else {
|
||||
queued = this->write_single_coil(this->write_address(), state);
|
||||
queued = this->write_single_coil(this->write_address(), write_value);
|
||||
}
|
||||
} else {
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<uint16_t, 1> states{static_cast<uint16_t>(state ? (0xFFFF & this->bitmask) : 0)};
|
||||
std::array<uint16_t, 1> states{static_cast<uint16_t>(write_value ? (0xFFFF & this->bitmask) : 0)};
|
||||
queued = this->write_multiple_registers(this->write_address(), states);
|
||||
} else {
|
||||
queued = this->write_single_register(this->write_address(), state ? 0xFFFF & this->bitmask : 0u);
|
||||
queued = this->write_single_register(this->write_address(), write_value ? 0xFFFF & this->bitmask : 0u);
|
||||
}
|
||||
}
|
||||
if (!queued) {
|
||||
|
||||
@@ -18,8 +18,13 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
|
||||
this->bitmask = bitmask;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->register_count = 1;
|
||||
if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) {
|
||||
this->set_address(this->start_address + offset);
|
||||
// A holding byte offset folds into the address as whole registers (odd offsets are rejected at
|
||||
// validation: a 16-bit register write cannot target half a register); a coil offset is a coil count.
|
||||
if (register_type == modbus::EntityType::HOLDING) {
|
||||
this->set_address(start_address + offset / 2);
|
||||
this->set_offset_from_start_address(0);
|
||||
} else if (register_type == modbus::EntityType::COIL) {
|
||||
this->set_address(start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
this->force_new_range = force_new_range;
|
||||
|
||||
@@ -188,6 +188,8 @@ struct IPAddress {
|
||||
}
|
||||
IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); }
|
||||
IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; }
|
||||
bool is_ip4() const { return true; }
|
||||
bool is_ip6() const { return false; }
|
||||
/// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes.
|
||||
char *str_to(char *buf) const {
|
||||
inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
@@ -23,6 +23,8 @@ class ProvisioningData:
|
||||
sources: set[str] = field(default_factory=set)
|
||||
# Names of source components that have their credentials set in the config.
|
||||
hardcoded_credentials: set[str] = field(default_factory=set)
|
||||
# True when WiFi is configured with an access point but no station credentials.
|
||||
ap_without_sta: bool = False
|
||||
|
||||
|
||||
def _get_data() -> ProvisioningData:
|
||||
@@ -56,6 +58,17 @@ def report_hardcoded_credentials(name: str) -> None:
|
||||
_get_data().hardcoded_credentials.add(name)
|
||||
|
||||
|
||||
def report_ap_without_sta() -> None:
|
||||
"""Record that WiFi runs an access point with no station credentials.
|
||||
|
||||
The access point (and captive portal) shut down when the provisioning window
|
||||
closes. On a device where that access point is the only network connection,
|
||||
closing the window makes the device unreachable until it is power-cycled, so
|
||||
`provisioning:` warns about this combination.
|
||||
"""
|
||||
_get_data().ap_without_sta = True
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ProvisioningManager),
|
||||
@@ -89,6 +102,13 @@ def _final_validate(config: ConfigType) -> None:
|
||||
"hardcoding them makes the window pointless.",
|
||||
", ".join(sorted(data.hardcoded_credentials)),
|
||||
)
|
||||
if data.ap_without_sta:
|
||||
_LOGGER.warning(
|
||||
"'provisioning' is configured with a WiFi access point and no station "
|
||||
"credentials. The access point shuts down when the provisioning window "
|
||||
"closes, so if it is the device's only network connection, the device "
|
||||
"will be unreachable until it is power-cycled."
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
@@ -28,6 +28,7 @@ ImageDecoder = runtime_image_ns.class_("ImageDecoder")
|
||||
BmpDecoder = runtime_image_ns.class_("BmpDecoder", ImageDecoder)
|
||||
JpegDecoder = runtime_image_ns.class_("JpegDecoder", ImageDecoder)
|
||||
PngDecoder = runtime_image_ns.class_("PngDecoder", ImageDecoder)
|
||||
QoiDecoder = runtime_image_ns.class_("QoiDecoder", ImageDecoder)
|
||||
|
||||
# Runtime image class
|
||||
RuntimeImage = runtime_image_ns.class_(
|
||||
@@ -37,9 +38,10 @@ RuntimeImage = runtime_image_ns.class_(
|
||||
# Image format enum
|
||||
ImageFormat = runtime_image_ns.enum("ImageFormat")
|
||||
IMAGE_FORMAT_AUTO = ImageFormat.AUTO
|
||||
IMAGE_FORMAT_BMP = ImageFormat.BMP
|
||||
IMAGE_FORMAT_JPEG = ImageFormat.JPEG
|
||||
IMAGE_FORMAT_PNG = ImageFormat.PNG
|
||||
IMAGE_FORMAT_BMP = ImageFormat.BMP
|
||||
IMAGE_FORMAT_QOI = ImageFormat.QOI
|
||||
|
||||
# Export enum for decode errors
|
||||
DecodeError = runtime_image_ns.enum("DecodeError")
|
||||
@@ -115,14 +117,27 @@ class PNGFormat(Format):
|
||||
cg.add_library("pngle", "1.1.0")
|
||||
|
||||
|
||||
class QOIFormat(Format):
|
||||
"""QOI format decoder configuration."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("QOI", QoiDecoder)
|
||||
|
||||
def actions(self) -> None:
|
||||
cg.add_define("USE_RUNTIME_IMAGE_QOI")
|
||||
|
||||
|
||||
# Decodable formats only; platforms that support runtime detection accept
|
||||
# "AUTO" in their own schema and get_format() resolves it
|
||||
_JPEG_FORMAT = JPEGFormat()
|
||||
|
||||
# Registry of available formats
|
||||
IMAGE_FORMATS = {
|
||||
"BMP": BMPFormat(),
|
||||
"JPEG": _JPEG_FORMAT,
|
||||
"JPG": _JPEG_FORMAT, # Alias for JPEG
|
||||
"PNG": PNGFormat(),
|
||||
"QOI": QOIFormat(),
|
||||
}
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
|
||||
@@ -21,6 +21,9 @@ static constexpr MimeLookup MIME_LOOKUP_TABLE[] = {
|
||||
#ifdef USE_RUNTIME_IMAGE_PNG
|
||||
{"image/png", ImageFormat::PNG}, {"image/x-png", ImageFormat::PNG},
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
{"image/qoi", ImageFormat::QOI}, {"image/x-qoi", ImageFormat::QOI},
|
||||
#endif
|
||||
};
|
||||
|
||||
const char *get_mime_type_for_format(ImageFormat format) {
|
||||
|
||||
@@ -11,12 +11,14 @@ enum ImageFormat {
|
||||
/** Format is supplied per decode, e.g. detected from the Content-Type header
|
||||
* by online_image; sniffing the image data is not implemented. */
|
||||
AUTO,
|
||||
/** BMP format. */
|
||||
BMP,
|
||||
/** JPEG format. */
|
||||
JPEG,
|
||||
/** PNG format. */
|
||||
PNG,
|
||||
/** BMP format. */
|
||||
BMP,
|
||||
/** QOI format. */
|
||||
QOI,
|
||||
};
|
||||
|
||||
/// Canonical MIME type for a format; "image/*" for AUTO/unknown
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include "qoi_decoder.h"
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
|
||||
#include "esphome/components/display/display.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
static const char *const TAG = "image_decoder.qoi";
|
||||
|
||||
constexpr uint8_t QOI_OP_RGB = 0b11111110;
|
||||
constexpr uint8_t QOI_OP_RGBA = 0b11111111;
|
||||
constexpr uint8_t QOI_OP_INDEX = 0b00000000; // 00xxxxxx
|
||||
constexpr uint8_t QOI_OP_DIFF = 0b01000000; // 01xxxxxx
|
||||
constexpr uint8_t QOI_OP_LUMA = 0b10000000; // 10xxxxxx
|
||||
constexpr uint8_t QOI_OP_RUN = 0b11000000; // 11xxxxxx
|
||||
|
||||
constexpr uint8_t QOI_RGB_CHUNK_SIZE = 4;
|
||||
constexpr uint8_t QOI_RGBA_CHUNK_SIZE = 5;
|
||||
constexpr uint8_t QOI_LUMA_CHUNK_SIZE = 2;
|
||||
|
||||
constexpr uint8_t QOI_MASK_OP = 0b11000000;
|
||||
constexpr uint8_t QOI_MASK_VALUE = 0b00111111;
|
||||
|
||||
constexpr size_t QOI_HEADER_SIZE = 14;
|
||||
constexpr size_t QOI_COLOR_TABLE_SIZE = 64;
|
||||
|
||||
inline size_t qoi_color_table_index(const Color &color) {
|
||||
// QOI color hash function: (r * 3 + g * 5 + b * 7 + a * 11) % 64
|
||||
return (color.r * 3 + color.g * 5 + color.b * 7 + color.w * 11) &
|
||||
63; // modulo 64 is equivalent to bitwise AND with 63 (0b00111111)
|
||||
}
|
||||
|
||||
void QoiDecoder::reset() {
|
||||
ImageDecoder::reset();
|
||||
this->current_index_ = 0;
|
||||
this->paint_index_ = 0;
|
||||
this->width_ = 0;
|
||||
this->height_ = 0;
|
||||
this->bits_per_pixel_ = 0;
|
||||
this->last_pixel_ = Color(0, 0, 0, 255);
|
||||
if (this->color_table_) {
|
||||
std::fill_n(this->color_table_.get(), QOI_COLOR_TABLE_SIZE, Color());
|
||||
}
|
||||
}
|
||||
|
||||
int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
size_t index = 0;
|
||||
if (this->current_index_ == 0) {
|
||||
if (size < QOI_HEADER_SIZE) {
|
||||
return 0; // Need more data for file header
|
||||
}
|
||||
|
||||
/** QOI Header definition, for reference:
|
||||
char magic[4]; // magic bytes "qoif"
|
||||
uint32_t width; // image width in pixels (BE)
|
||||
uint32_t height; // image height in pixels (BE)
|
||||
uint8_t channels; // 3 = RGB, 4 = RGBA
|
||||
uint8_t colorspace; // 0 = sRGB with linear alpha, 1 = all channels linear
|
||||
*/
|
||||
// Check if the file is a QOI image
|
||||
if (buffer[0] != 'q' || buffer[1] != 'o' || buffer[2] != 'i' || buffer[3] != 'f') {
|
||||
ESP_LOGE(TAG, "Not a QOI file");
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
}
|
||||
|
||||
this->width_ = encode_uint32(buffer[4], buffer[5], buffer[6], buffer[7]);
|
||||
this->height_ = encode_uint32(buffer[8], buffer[9], buffer[10], buffer[11]);
|
||||
if (this->width_ == 0 || this->height_ == 0) {
|
||||
ESP_LOGE(TAG, "Invalid image dimensions: (%zux%zu)", this->width_, this->height_);
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
}
|
||||
uint8_t channels = buffer[12];
|
||||
if (channels < 3 || channels > 4) {
|
||||
ESP_LOGE(TAG, "Unsupported number of channels: %d", channels);
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
this->bits_per_pixel_ = channels * 8;
|
||||
uint8_t colorspace = buffer[13];
|
||||
if (colorspace > 1) {
|
||||
ESP_LOGE(TAG, "Unsupported colorspace value: %d", colorspace);
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
ESP_LOGD(TAG, "QOI image header: width=%zu, height=%zu, channels=%d, colorspace=%d", this->width_, this->height_,
|
||||
channels, colorspace);
|
||||
|
||||
if (!this->color_table_) {
|
||||
this->color_table_ = std::make_unique<Color[]>(QOI_COLOR_TABLE_SIZE);
|
||||
}
|
||||
|
||||
if (!this->set_size(this->width_, this->height_)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
|
||||
this->current_index_ = QOI_HEADER_SIZE;
|
||||
index = QOI_HEADER_SIZE;
|
||||
} // Current_index == 0
|
||||
|
||||
Color color;
|
||||
const size_t total_pixels = this->width_ * this->height_;
|
||||
while (index < size && this->paint_index_ < total_pixels) {
|
||||
color = this->last_pixel_;
|
||||
uint8_t byte = buffer[index];
|
||||
if (byte == QOI_OP_RGB) {
|
||||
if (size < index + QOI_RGB_CHUNK_SIZE) {
|
||||
return index; // Need more data for RGB chunk
|
||||
}
|
||||
index++;
|
||||
color.r = buffer[index++];
|
||||
color.g = buffer[index++];
|
||||
color.b = buffer[index++];
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
} else if (byte == QOI_OP_RGBA) {
|
||||
if (size < index + QOI_RGBA_CHUNK_SIZE) {
|
||||
return index; // Need more data for RGBA chunk
|
||||
}
|
||||
index++;
|
||||
color.r = buffer[index++];
|
||||
color.g = buffer[index++];
|
||||
color.b = buffer[index++];
|
||||
color.w = buffer[index++];
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_RUN) {
|
||||
// QOI run chunk
|
||||
size_t run_length = (byte & QOI_MASK_VALUE) + 1; // run length is encoded in the lower 6 bits, plus one
|
||||
for (size_t i = 0; i < run_length; i++) {
|
||||
// TODO: optimize by drawing runs of pixels at once instead of one by one
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
}
|
||||
index++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_LUMA) {
|
||||
if (size < index + QOI_LUMA_CHUNK_SIZE) {
|
||||
return index; // Need more data for LUMA chunk
|
||||
}
|
||||
index++;
|
||||
uint8_t byte2 = buffer[index++];
|
||||
uint8_t delta_g = (byte & QOI_MASK_VALUE) - 32;
|
||||
color.r += delta_g - 8 + ((byte2 >> 4) & 0x0f);
|
||||
color.g += delta_g;
|
||||
color.b += delta_g - 8 + (byte2 & 0x0f);
|
||||
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_DIFF) {
|
||||
color.r += ((byte >> 4) & 0x03) - 2;
|
||||
color.g += ((byte >> 2) & 0x03) - 2;
|
||||
color.b += (byte & 0x03) - 2;
|
||||
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
index++;
|
||||
} else if ((byte & QOI_MASK_OP) == QOI_OP_INDEX) {
|
||||
color = this->color_table_[byte];
|
||||
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
|
||||
this->paint_index_++;
|
||||
index++;
|
||||
}
|
||||
this->last_pixel_ = color;
|
||||
this->color_table_[qoi_color_table_index(color)] = color;
|
||||
}
|
||||
this->decoded_bytes_ += size;
|
||||
return size;
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image
|
||||
|
||||
#endif // USE_RUNTIME_IMAGE_QOI
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "image_decoder.h"
|
||||
#include "runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
/**
|
||||
* @brief Image decoder specialization for QOI images.
|
||||
*/
|
||||
class QoiDecoder : public ImageDecoder {
|
||||
public:
|
||||
/**
|
||||
* @brief Construct a new QOI decoder object.
|
||||
*
|
||||
* @param image The RuntimeImage to decode the stream into.
|
||||
*/
|
||||
QoiDecoder(RuntimeImage *image) : ImageDecoder(image, QOI) {}
|
||||
|
||||
void reset() override;
|
||||
int HOT decode(uint8_t *buffer, size_t size) override;
|
||||
|
||||
bool is_finished() const override {
|
||||
if (this->bits_per_pixel_ == 0) {
|
||||
// header not yet received, so dimensions not yet determined
|
||||
return false;
|
||||
}
|
||||
// QOI is finished when we've decoded all pixel data
|
||||
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
|
||||
}
|
||||
|
||||
protected:
|
||||
std::unique_ptr<Color[]> color_table_;
|
||||
size_t current_index_{0};
|
||||
size_t paint_index_{0};
|
||||
size_t width_{0};
|
||||
size_t height_{0};
|
||||
Color last_pixel_{0, 0, 0, 255}; // QOI spec defines initial previous pixel as opaque black
|
||||
uint16_t bits_per_pixel_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::runtime_image
|
||||
|
||||
#endif // USE_RUNTIME_IMAGE_QOI
|
||||
@@ -15,6 +15,9 @@
|
||||
#ifdef USE_RUNTIME_IMAGE_PNG
|
||||
#include "png_decoder.h"
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
#include "qoi_decoder.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
@@ -367,6 +370,10 @@ std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_(ImageFormat format)
|
||||
#ifdef USE_RUNTIME_IMAGE_PNG
|
||||
case PNG:
|
||||
return make_unique<PngDecoder>(this);
|
||||
#endif
|
||||
#ifdef USE_RUNTIME_IMAGE_QOI
|
||||
case QOI:
|
||||
return make_unique<QoiDecoder>(this);
|
||||
#endif
|
||||
case AUTO:
|
||||
ESP_LOGE(TAG, "Image format could not be determined; set `format:` explicitly in the configuration");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import water_heater
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SUPPORTED_MODES, CONF_SWITCH_DATAPOINT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_TUYA_ID, Tuya, tuya_ns
|
||||
|
||||
DEPENDENCIES = ["tuya"]
|
||||
CODEOWNERS = ["@iago-veiga"]
|
||||
|
||||
CONF_TARGET_TEMPERATURE_DATAPOINT = "target_temperature_datapoint"
|
||||
CONF_CURRENT_TEMPERATURE_DATAPOINT = "current_temperature_datapoint"
|
||||
CONF_TARGET_TEMPERATURE_MULTIPLIER = "target_temperature_multiplier"
|
||||
CONF_CURRENT_TEMPERATURE_MULTIPLIER = "current_temperature_multiplier"
|
||||
CONF_MODE_DATAPOINT = "mode_datapoint"
|
||||
|
||||
# Optional enum values that map a Tuya mode datapoint value to a WaterHeaterMode.
|
||||
# Mirrors the "*_value" style used by the tuya climate fan modes.
|
||||
CONF_ECO_VALUE = "eco_value"
|
||||
CONF_ELECTRIC_VALUE = "electric_value"
|
||||
CONF_PERFORMANCE_VALUE = "performance_value"
|
||||
CONF_HIGH_DEMAND_VALUE = "high_demand_value"
|
||||
CONF_HEAT_PUMP_VALUE = "heat_pump_value"
|
||||
CONF_GAS_VALUE = "gas_value"
|
||||
|
||||
# Map of config key -> C++ setter name, one per non-OFF WaterHeaterMode. OFF is not an enum
|
||||
# value: it is represented by the switch datapoint being off, just like the tuya climate.
|
||||
MODE_VALUES = {
|
||||
CONF_ECO_VALUE: "set_eco_value",
|
||||
CONF_ELECTRIC_VALUE: "set_electric_value",
|
||||
CONF_PERFORMANCE_VALUE: "set_performance_value",
|
||||
CONF_HIGH_DEMAND_VALUE: "set_high_demand_value",
|
||||
CONF_HEAT_PUMP_VALUE: "set_heat_pump_value",
|
||||
CONF_GAS_VALUE: "set_gas_value",
|
||||
}
|
||||
|
||||
TuyaWaterHeater = tuya_ns.class_(
|
||||
"TuyaWaterHeater", water_heater.WaterHeater, cg.Component
|
||||
)
|
||||
|
||||
|
||||
def _validate(config: ConfigType) -> ConfigType:
|
||||
# A mode datapoint is only useful if at least one mode value is mapped, and mode values
|
||||
# only make sense together with a mode datapoint.
|
||||
has_mode_values = any(key in config for key in MODE_VALUES)
|
||||
if CONF_MODE_DATAPOINT in config and not has_mode_values:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_MODE_DATAPOINT}' requires at least one mode value "
|
||||
f"(e.g. '{CONF_ECO_VALUE}' or '{CONF_ELECTRIC_VALUE}')"
|
||||
)
|
||||
if has_mode_values and CONF_MODE_DATAPOINT not in config:
|
||||
raise cv.Invalid(f"Mode values require '{CONF_MODE_DATAPOINT}' to be set")
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
water_heater.water_heater_schema(TuyaWaterHeater)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya),
|
||||
cv.Required(CONF_SWITCH_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(CONF_TARGET_TEMPERATURE_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(CONF_CURRENT_TEMPERATURE_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(
|
||||
CONF_TARGET_TEMPERATURE_MULTIPLIER, default=1.0
|
||||
): cv.positive_float,
|
||||
cv.Optional(
|
||||
CONF_CURRENT_TEMPERATURE_MULTIPLIER, default=1.0
|
||||
): cv.positive_float,
|
||||
cv.Optional(CONF_MODE_DATAPOINT): cv.uint8_t,
|
||||
cv.Optional(CONF_ECO_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_ELECTRIC_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_PERFORMANCE_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_HIGH_DEMAND_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_HEAT_PUMP_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_GAS_VALUE): cv.uint8_t,
|
||||
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(
|
||||
water_heater.validate_water_heater_mode
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_validate,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await water_heater.new_water_heater(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
paren = await cg.get_variable(config[CONF_TUYA_ID])
|
||||
cg.add(var.set_tuya_parent(paren))
|
||||
|
||||
cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT]))
|
||||
|
||||
if (target_temp_dp := config.get(CONF_TARGET_TEMPERATURE_DATAPOINT)) is not None:
|
||||
cg.add(var.set_target_temperature_id(target_temp_dp))
|
||||
if (current_temp_dp := config.get(CONF_CURRENT_TEMPERATURE_DATAPOINT)) is not None:
|
||||
cg.add(var.set_current_temperature_id(current_temp_dp))
|
||||
|
||||
cg.add(
|
||||
var.set_target_temperature_multiplier(
|
||||
config[CONF_TARGET_TEMPERATURE_MULTIPLIER]
|
||||
)
|
||||
)
|
||||
cg.add(
|
||||
var.set_current_temperature_multiplier(
|
||||
config[CONF_CURRENT_TEMPERATURE_MULTIPLIER]
|
||||
)
|
||||
)
|
||||
|
||||
if (mode_dp := config.get(CONF_MODE_DATAPOINT)) is not None:
|
||||
cg.add(var.set_mode_id(mode_dp))
|
||||
for key, setter in MODE_VALUES.items():
|
||||
if (value := config.get(key)) is not None:
|
||||
cg.add(getattr(var, setter)(value))
|
||||
|
||||
if (supported_modes := config.get(CONF_SUPPORTED_MODES)) is not None:
|
||||
cg.add(var.set_supported_modes(supported_modes))
|
||||
@@ -0,0 +1,190 @@
|
||||
#include "tuya_water_heater.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::tuya {
|
||||
|
||||
static const char *const TAG = "tuya.water_heater";
|
||||
|
||||
void TuyaWaterHeater::setup() {
|
||||
if (this->switch_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
ESP_LOGV(TAG, "MCU reported switch is: %s", ONOFF(datapoint.value_bool));
|
||||
this->is_on_ = datapoint.value_bool;
|
||||
this->set_state_flag_(water_heater::WATER_HEATER_STATE_ON, this->is_on_);
|
||||
if (!this->is_on_) {
|
||||
this->set_mode_(water_heater::WATER_HEATER_MODE_OFF);
|
||||
} else {
|
||||
// Turned on: use the last mode reported by the mode datapoint if we have one, otherwise
|
||||
// fall back to a supported mode. Datapoints can arrive in any order, so the mode enum may
|
||||
// have been reported before this switch update.
|
||||
this->set_mode_(this->last_reported_mode_.value_or(this->default_on_mode_()));
|
||||
}
|
||||
this->publish_state();
|
||||
});
|
||||
}
|
||||
|
||||
if (this->mode_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->mode_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
ESP_LOGV(TAG, "MCU reported mode value is: %u", datapoint.value_enum);
|
||||
water_heater::WaterHeaterMode mode;
|
||||
if (!this->mode_from_value_(datapoint.value_enum, mode)) {
|
||||
return;
|
||||
}
|
||||
// Always remember the reported mode; only surface it while the heater is on (OFF is driven
|
||||
// by the switch datapoint, not the mode enum).
|
||||
this->last_reported_mode_ = mode;
|
||||
if (this->is_on_ && this->mode_ != mode) {
|
||||
this->set_mode_(mode);
|
||||
this->publish_state();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this->target_temperature_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->target_temperature_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
float value = datapoint.value_int * this->target_temperature_multiplier_;
|
||||
ESP_LOGV(TAG, "MCU reported target temperature is: %.1f", value);
|
||||
this->set_target_temperature_(value);
|
||||
this->publish_state();
|
||||
});
|
||||
}
|
||||
|
||||
if (this->current_temperature_id_.has_value()) {
|
||||
this->parent_->register_listener(*this->current_temperature_id_, [this](const TuyaDatapoint &datapoint) {
|
||||
float value = datapoint.value_int * this->current_temperature_multiplier_;
|
||||
ESP_LOGV(TAG, "MCU reported current temperature is: %.1f", value);
|
||||
this->set_current_temperature(value);
|
||||
this->publish_state();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterCallInternal TuyaWaterHeater::make_call() {
|
||||
return water_heater::WaterHeaterCallInternal(this);
|
||||
}
|
||||
|
||||
void TuyaWaterHeater::control(const water_heater::WaterHeaterCall &call) {
|
||||
auto mode_val = call.get_mode();
|
||||
auto on_val = call.get_on();
|
||||
|
||||
// Determine the desired on/off state. An explicit on/off request wins; otherwise a mode of
|
||||
// OFF means off and any other mode means on.
|
||||
optional<bool> want_on = on_val;
|
||||
if (mode_val.has_value() && !want_on.has_value()) {
|
||||
want_on = *mode_val != water_heater::WATER_HEATER_MODE_OFF;
|
||||
}
|
||||
|
||||
if (want_on.has_value() && this->switch_id_.has_value()) {
|
||||
ESP_LOGV(TAG, "Setting switch: %s", ONOFF(*want_on));
|
||||
this->parent_->set_boolean_datapoint_value(*this->switch_id_, *want_on);
|
||||
}
|
||||
|
||||
if (mode_val.has_value() && *mode_val != water_heater::WATER_HEATER_MODE_OFF && this->mode_id_.has_value()) {
|
||||
uint8_t value;
|
||||
if (this->value_from_mode_(*mode_val, value)) {
|
||||
ESP_LOGV(TAG, "Setting mode value: %u", value);
|
||||
this->parent_->set_enum_datapoint_value(*this->mode_id_, value);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No mode value configured for requested mode");
|
||||
}
|
||||
}
|
||||
|
||||
auto target_temp = call.get_target_temperature();
|
||||
if (!std::isnan(target_temp) && this->target_temperature_id_.has_value()) {
|
||||
ESP_LOGV(TAG, "Setting target temperature: %.1f", target_temp);
|
||||
this->parent_->set_integer_datapoint_value(*this->target_temperature_id_,
|
||||
(int) (target_temp / this->target_temperature_multiplier_));
|
||||
}
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterTraits TuyaWaterHeater::traits() {
|
||||
water_heater::WaterHeaterTraits traits;
|
||||
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_ON_OFF);
|
||||
if (this->current_temperature_id_.has_value()) {
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
if (this->target_temperature_id_.has_value()) {
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_TARGET_TEMPERATURE);
|
||||
}
|
||||
if (!this->supported_modes_.empty()) {
|
||||
traits.set_supported_modes(this->supported_modes_);
|
||||
traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE);
|
||||
}
|
||||
return traits;
|
||||
}
|
||||
|
||||
bool TuyaWaterHeater::mode_from_value_(uint8_t value, water_heater::WaterHeaterMode &mode) const {
|
||||
if (this->eco_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_ECO;
|
||||
} else if (this->electric_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_ELECTRIC;
|
||||
} else if (this->performance_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_PERFORMANCE;
|
||||
} else if (this->high_demand_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_HIGH_DEMAND;
|
||||
} else if (this->heat_pump_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_HEAT_PUMP;
|
||||
} else if (this->gas_value_ == value) {
|
||||
mode = water_heater::WATER_HEATER_MODE_GAS;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TuyaWaterHeater::value_from_mode_(water_heater::WaterHeaterMode mode, uint8_t &value) const {
|
||||
optional<uint8_t> mapped;
|
||||
switch (mode) {
|
||||
case water_heater::WATER_HEATER_MODE_ECO:
|
||||
mapped = this->eco_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_ELECTRIC:
|
||||
mapped = this->electric_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_PERFORMANCE:
|
||||
mapped = this->performance_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_HIGH_DEMAND:
|
||||
mapped = this->high_demand_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_HEAT_PUMP:
|
||||
mapped = this->heat_pump_value_;
|
||||
break;
|
||||
case water_heater::WATER_HEATER_MODE_GAS:
|
||||
mapped = this->gas_value_;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (mapped.has_value()) {
|
||||
value = *mapped;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterMode TuyaWaterHeater::default_on_mode_() const {
|
||||
// Prefer the first configured supported non-OFF mode so we never surface a mode the user
|
||||
// cannot control. Fall back to ELECTRIC when no supported modes are configured.
|
||||
for (water_heater::WaterHeaterMode mode : this->supported_modes_) {
|
||||
if (mode != water_heater::WATER_HEATER_MODE_OFF) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return water_heater::WATER_HEATER_MODE_ELECTRIC;
|
||||
}
|
||||
|
||||
void TuyaWaterHeater::dump_config() {
|
||||
LOG_WATER_HEATER("", "Tuya Water Heater", this);
|
||||
if (this->switch_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Switch has datapoint ID %u", *this->switch_id_);
|
||||
if (this->mode_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Mode has datapoint ID %u", *this->mode_id_);
|
||||
if (this->target_temperature_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Target Temperature has datapoint ID %u", *this->target_temperature_id_);
|
||||
if (this->current_temperature_id_.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Current Temperature has datapoint ID %u", *this->current_temperature_id_);
|
||||
}
|
||||
|
||||
} // namespace esphome::tuya
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/tuya/tuya.h"
|
||||
#include "esphome/components/water_heater/water_heater.h"
|
||||
|
||||
namespace esphome::tuya {
|
||||
|
||||
class TuyaWaterHeater final : public water_heater::WaterHeater, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_tuya_parent(Tuya *parent) { this->parent_ = parent; }
|
||||
|
||||
void set_switch_id(uint8_t switch_id) { this->switch_id_ = switch_id; }
|
||||
void set_target_temperature_id(uint8_t target_temperature_id) {
|
||||
this->target_temperature_id_ = target_temperature_id;
|
||||
}
|
||||
void set_current_temperature_id(uint8_t current_temperature_id) {
|
||||
this->current_temperature_id_ = current_temperature_id;
|
||||
}
|
||||
void set_target_temperature_multiplier(float multiplier) { this->target_temperature_multiplier_ = multiplier; }
|
||||
void set_current_temperature_multiplier(float multiplier) { this->current_temperature_multiplier_ = multiplier; }
|
||||
|
||||
void set_mode_id(uint8_t mode_id) { this->mode_id_ = mode_id; }
|
||||
void set_eco_value(uint8_t value) { this->eco_value_ = value; }
|
||||
void set_electric_value(uint8_t value) { this->electric_value_ = value; }
|
||||
void set_performance_value(uint8_t value) { this->performance_value_ = value; }
|
||||
void set_high_demand_value(uint8_t value) { this->high_demand_value_ = value; }
|
||||
void set_heat_pump_value(uint8_t value) { this->heat_pump_value_ = value; }
|
||||
void set_gas_value(uint8_t value) { this->gas_value_ = value; }
|
||||
|
||||
void set_supported_modes(const std::initializer_list<water_heater::WaterHeaterMode> &modes) {
|
||||
this->supported_modes_ = modes;
|
||||
}
|
||||
|
||||
water_heater::WaterHeaterCallInternal make_call() override;
|
||||
|
||||
protected:
|
||||
void control(const water_heater::WaterHeaterCall &call) override;
|
||||
water_heater::WaterHeaterTraits traits() override;
|
||||
|
||||
/// Map a Tuya mode datapoint enum value to a WaterHeaterMode. Returns true when a mapping
|
||||
/// exists, writing the result to \p mode.
|
||||
bool mode_from_value_(uint8_t value, water_heater::WaterHeaterMode &mode) const;
|
||||
/// Map a WaterHeaterMode to its configured Tuya enum value. Returns true when a mapping exists.
|
||||
bool value_from_mode_(water_heater::WaterHeaterMode mode, uint8_t &value) const;
|
||||
|
||||
Tuya *parent_{nullptr};
|
||||
optional<uint8_t> switch_id_{};
|
||||
optional<uint8_t> target_temperature_id_{};
|
||||
optional<uint8_t> current_temperature_id_{};
|
||||
optional<uint8_t> mode_id_{};
|
||||
optional<uint8_t> eco_value_{};
|
||||
optional<uint8_t> electric_value_{};
|
||||
optional<uint8_t> performance_value_{};
|
||||
optional<uint8_t> high_demand_value_{};
|
||||
optional<uint8_t> heat_pump_value_{};
|
||||
optional<uint8_t> gas_value_{};
|
||||
float target_temperature_multiplier_{1.0f};
|
||||
float current_temperature_multiplier_{1.0f};
|
||||
water_heater::WaterHeaterModeMask supported_modes_;
|
||||
/// Last non-OFF mode reported by the mode datapoint, applied when the heater turns on.
|
||||
optional<water_heater::WaterHeaterMode> last_reported_mode_{};
|
||||
bool is_on_{false};
|
||||
|
||||
/// The mode to show when the heater is on but no mode datapoint value is known yet: the last
|
||||
/// reported mode, else the first configured supported non-OFF mode, else ELECTRIC.
|
||||
water_heater::WaterHeaterMode default_on_mode_() const;
|
||||
};
|
||||
|
||||
} // namespace esphome::tuya
|
||||
@@ -445,10 +445,15 @@ def _report_provisioning_credentials(config):
|
||||
about this, since a device that uses a provisioning window should get its
|
||||
credentials on first connection instead.
|
||||
"""
|
||||
if config.get(CONF_NETWORKS):
|
||||
from esphome.components import provisioning
|
||||
from esphome.components import provisioning
|
||||
|
||||
if config.get(CONF_NETWORKS):
|
||||
provisioning.report_hardcoded_credentials("wifi")
|
||||
elif CONF_AP in config:
|
||||
# An access point with no station credentials: the AP shuts down when the
|
||||
# provisioning window closes, so `provisioning:` warns that the device may
|
||||
# become unreachable until power-cycled.
|
||||
provisioning.report_ap_without_sta()
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -633,6 +633,21 @@ void WiFiComponent::setup() {
|
||||
this->configured_power_save_ = this->power_save_;
|
||||
#endif
|
||||
|
||||
#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP)
|
||||
// The access point is a provisioning surface: once the provisioning window has
|
||||
// closed, shut it down (mirrors the teardown done on a successful connection).
|
||||
// The captive portal registers its own closed-callback, and the fallback block
|
||||
// in loop() is gated so neither is started again afterwards.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
if (this->ap_setup_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->enable_on_boot_) {
|
||||
#ifdef USE_ESP32
|
||||
this->wifi_lazy_init_();
|
||||
@@ -854,7 +869,15 @@ void WiFiComponent::loop() {
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
if (this->has_ap() && !this->ap_setup_) {
|
||||
bool provisioning_closed = false;
|
||||
#ifdef USE_PROVISIONING
|
||||
// Once the provisioning window has closed, don't bring up the fallback AP (or
|
||||
// the captive portal on it) - the device must stay unprovisionable until it is
|
||||
// power-cycled.
|
||||
provisioning_closed =
|
||||
provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed();
|
||||
#endif
|
||||
if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) {
|
||||
if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) {
|
||||
ESP_LOGI(TAG, "Starting fallback AP");
|
||||
this->setup_ap_config_();
|
||||
|
||||
@@ -81,8 +81,6 @@
|
||||
#define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT
|
||||
#define USE_I2S_AUDIO_SPDIF_MODE
|
||||
#define USE_IMAGE
|
||||
#define USE_IMPROV_SERIAL
|
||||
#define USE_IMPROV_SERIAL_NEXT_URL
|
||||
#define USE_INFRARED
|
||||
#define USE_IR_RF
|
||||
#define USE_JSON
|
||||
@@ -223,6 +221,9 @@
|
||||
#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#define API_MAX_SEND_QUEUE 8
|
||||
#define MAX_API_CONNECTIONS 6
|
||||
// The Improv library is not in the Zephyr tidy environment
|
||||
#define USE_IMPROV_SERIAL
|
||||
#define USE_IMPROV_SERIAL_NEXT_URL
|
||||
#define USE_MD5
|
||||
#define USE_NOISE
|
||||
#define USE_SHA256
|
||||
@@ -233,8 +234,9 @@
|
||||
#endif
|
||||
#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
|
||||
#define USE_RUNTIME_IMAGE_BMP
|
||||
#define USE_RUNTIME_IMAGE_PNG
|
||||
#define USE_RUNTIME_IMAGE_JPEG
|
||||
#define USE_RUNTIME_IMAGE_PNG
|
||||
#define USE_RUNTIME_IMAGE_QOI
|
||||
#define USE_RUNTIME_STATS
|
||||
#define USE_OTA
|
||||
#define USE_OTA_PASSWORD
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Config validation for the byte offset on holding-register write entities.
|
||||
|
||||
A 16-bit register write cannot target half a register, so an odd offset (or byte_offset) is
|
||||
rejected for holding-register switches and outputs; even offsets and coil offsets pass.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components.modbus_controller.output import (
|
||||
CONFIG_SCHEMA as OUTPUT_CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.components.modbus_controller.switch import (
|
||||
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_NAME, CONF_OFFSET
|
||||
|
||||
|
||||
def _switch_config(register_type: str, offset: int) -> dict:
|
||||
return {
|
||||
CONF_NAME: "test switch",
|
||||
CONF_ADDRESS: 0x10,
|
||||
"register_type": register_type,
|
||||
CONF_OFFSET: offset,
|
||||
}
|
||||
|
||||
|
||||
def _output_config(register_type: str, offset: int) -> dict:
|
||||
return {
|
||||
CONF_ID: "test_output",
|
||||
CONF_ADDRESS: 0x10,
|
||||
"register_type": register_type,
|
||||
CONF_OFFSET: offset,
|
||||
}
|
||||
|
||||
|
||||
def test_odd_offset_on_holding_switch_rejected() -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
|
||||
SWITCH_CONFIG_SCHEMA(_switch_config("holding", 3))
|
||||
|
||||
|
||||
def test_even_offset_on_holding_switch_accepted() -> None:
|
||||
config = SWITCH_CONFIG_SCHEMA(_switch_config("holding", 2))
|
||||
assert config[CONF_OFFSET] == 2
|
||||
|
||||
|
||||
def test_odd_offset_on_coil_switch_accepted() -> None:
|
||||
"""A coil offset is a coil count, so odd values are fine."""
|
||||
config = SWITCH_CONFIG_SCHEMA(_switch_config("coil", 3))
|
||||
assert config[CONF_OFFSET] == 3
|
||||
|
||||
|
||||
def test_odd_byte_offset_on_holding_switch_rejected() -> None:
|
||||
"""byte_offset is the alias the validator must also catch."""
|
||||
config = _switch_config("holding", 0)
|
||||
del config[CONF_OFFSET]
|
||||
config["byte_offset"] = 3
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="byte_offset"):
|
||||
SWITCH_CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def test_odd_offset_on_holding_output_rejected() -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
|
||||
OUTPUT_CONFIG_SCHEMA(_output_config("holding", 3))
|
||||
|
||||
|
||||
def test_even_offset_on_holding_output_accepted() -> None:
|
||||
config = OUTPUT_CONFIG_SCHEMA(_output_config("holding", 2))
|
||||
assert config[CONF_OFFSET] == 2
|
||||
|
||||
|
||||
def test_odd_offset_on_coil_output_accepted() -> None:
|
||||
config = OUTPUT_CONFIG_SCHEMA(_output_config("coil", 3))
|
||||
assert config[CONF_OFFSET] == 3
|
||||
@@ -11,6 +11,7 @@ from esphome.components.provisioning import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
register_source,
|
||||
report_ap_without_sta,
|
||||
report_hardcoded_credentials,
|
||||
)
|
||||
from esphome.const import CONF_TIMEOUT, PlatformFramework
|
||||
@@ -66,6 +67,32 @@ def test_provisioning_no_warning_without_hardcoded_credentials(
|
||||
assert "credentials" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_warns_on_ap_without_sta(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An access point with no station credentials triggers a reachability warning."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
report_ap_without_sta()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "access point" in caplog.text
|
||||
assert "unreachable" in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_no_warning_without_ap(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""No reachability warning when no AP-without-station setup is reported."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "access point" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_rejects_zero_timeout(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# The builder test compares against the Improv library's build_rpc_response,
|
||||
# so the library must be part of the unit test build.
|
||||
# Keep the version in sync with the pin in esphome/components/improv_base/__init__.py.
|
||||
esphome:
|
||||
libraries:
|
||||
- improv/Improv@1.2.7
|
||||
@@ -0,0 +1,102 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <improv.h>
|
||||
|
||||
namespace esphome::improv_base::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<uint8_t> build_with_builder(improv::Command command, const std::vector<std::string> &datum,
|
||||
bool add_checksum) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, command);
|
||||
for (const auto &str : datum) {
|
||||
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
|
||||
}
|
||||
auto out = builder.finish(add_checksum);
|
||||
return {out.begin(), out.end()};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// The serial path sends builder output where build_rpc_response bytes went before,
|
||||
// so the two must match exactly, including the trailing 0x00 when checksums are off.
|
||||
TEST(RpcResponseBuilder, ByteIdenticalToBuildRpcResponse) {
|
||||
const std::vector<std::string> device_info = {"ESPHome", "2026.9.0", "ESP32", "test-device"};
|
||||
const std::vector<std::string> network = {"MySSID", "-67", "YES"};
|
||||
const std::vector<std::string> empty = {};
|
||||
const std::vector<std::string> max_payload = {std::string(254, 'x')};
|
||||
|
||||
for (bool add_checksum : {false, true}) {
|
||||
for (const auto *datum : {&device_info, &network, &empty, &max_payload}) {
|
||||
EXPECT_EQ(build_with_builder(improv::GET_DEVICE_INFO, *datum, add_checksum),
|
||||
improv::build_rpc_response(improv::GET_DEVICE_INFO, *datum, add_checksum));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden bytes independent of the library: command, data length, string entries,
|
||||
// then the trailing byte (0x00 without checksum, additive checksum with).
|
||||
TEST(RpcResponseBuilder, GoldenBytes) {
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {}, false), (std::vector<uint8_t>{0x04, 0x00, 0x00}));
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, false),
|
||||
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0x00}));
|
||||
// Checksum: 0x04 + 0x03 + 0x02 + 'a' + 'b' = 0xCC
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, true),
|
||||
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0xCC}));
|
||||
}
|
||||
|
||||
// esp32_improv calls finish() and build_rpc_response() with no checksum flag,
|
||||
// so the two defaults must agree
|
||||
TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) {
|
||||
const std::vector<std::string> urls = {"https://example.com"};
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
|
||||
for (const auto &str : urls) {
|
||||
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
|
||||
}
|
||||
auto out = builder.finish();
|
||||
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), improv::build_rpc_response(improv::WIFI_SETTINGS, urls));
|
||||
}
|
||||
|
||||
TEST(RpcResponseBuilder, PayloadBudget) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
|
||||
// 254 byte string fills the payload exactly; a second entry no longer fits
|
||||
improv::RpcResponseBuilder full(buf, improv::GET_DEVICE_INFO);
|
||||
const std::string big(254, 'x');
|
||||
EXPECT_TRUE(full.add_string(big.c_str(), big.size()));
|
||||
EXPECT_FALSE(full.add_string("y", 1));
|
||||
|
||||
// 255 byte string can never fit (its length byte would exceed the budget)
|
||||
improv::RpcResponseBuilder over(buf, improv::GET_DEVICE_INFO);
|
||||
const std::string too_big(255, 'y');
|
||||
EXPECT_FALSE(over.add_string(too_big.c_str(), too_big.size()));
|
||||
// A wildly out of range length must not wrap the position arithmetic
|
||||
EXPECT_FALSE(over.add_string("z", static_cast<size_t>(-1)));
|
||||
auto out = over.finish(false);
|
||||
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), (std::vector<uint8_t>{0x03, 0x00, 0x00}));
|
||||
}
|
||||
|
||||
TEST(RpcResponseBuilder, FinishIsIdempotent) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
|
||||
EXPECT_TRUE(builder.add_string("abc", 3));
|
||||
auto first = builder.finish(true);
|
||||
const std::vector<uint8_t> expected(first.begin(), first.end());
|
||||
|
||||
EXPECT_FALSE(builder.add_string("late", 4));
|
||||
auto again = builder.finish(true);
|
||||
EXPECT_EQ(std::vector<uint8_t>(again.begin(), again.end()), expected);
|
||||
// The checksum flag on a later call is ignored
|
||||
auto no_checksum = builder.finish(false);
|
||||
EXPECT_EQ(std::vector<uint8_t>(no_checksum.begin(), no_checksum.end()), expected);
|
||||
}
|
||||
|
||||
} // namespace esphome::improv_base::testing
|
||||
@@ -5,4 +5,6 @@ wifi:
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
|
||||
# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_
|
||||
improv_serial:
|
||||
next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}}
|
||||
|
||||
@@ -62,6 +62,12 @@ image:
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
- platform: online_image
|
||||
id: online_qoi_image
|
||||
url: https://www.example.org/image.qoi
|
||||
format: QOI
|
||||
type: RGB
|
||||
transparency: alpha_channel
|
||||
|
||||
# Check the set_url action
|
||||
esphome:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Exercises the provisioning window: api registers as a provisioning source
|
||||
# (encryption enabled, no key), the on_timeout automation, and the wifi +
|
||||
# esp32_improv cross-component guards. improv_serial is intentionally NOT gated.
|
||||
# (encryption enabled, no key), the on_timeout automation, and the wifi (AP +
|
||||
# captive portal) and esp32_improv cross-component guards. improv_serial is
|
||||
# intentionally NOT gated.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
on_timeout:
|
||||
@@ -13,6 +14,10 @@ api:
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: MyAP
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source
|
||||
# and the wifi reboot guard. improv_serial is present and intentionally NOT gated.
|
||||
# and the wifi (AP + captive portal) guards. improv_serial is present and
|
||||
# intentionally NOT gated.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
on_timeout:
|
||||
@@ -12,5 +13,9 @@ api:
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: MyAP
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
|
||||
@@ -9,7 +9,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# tests have two decoder types and every retained decoder is under test.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
enable_format("BMP")
|
||||
enable_format("PNG")
|
||||
enable_format("JPEG")
|
||||
enable_format("PNG")
|
||||
enable_format("QOI")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -70,11 +70,36 @@ static const uint8_t PNG_RGB_EXPECTED[4][4][3] = {
|
||||
{{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}},
|
||||
};
|
||||
|
||||
// 3x3 QOI, exercising all possible chunk types
|
||||
static const uint8_t QOI_RGBA[] = {
|
||||
0x71, 0x6F, 0x69, 0x66, // Header: 'qoif'
|
||||
0x00, 0x00, 0x00, 0x03, // Width: 3
|
||||
0x00, 0x00, 0x00, 0x03, // Height: 3
|
||||
0x04, // Channels: 4 (RGBA)
|
||||
0x00, // Colorspace: 0 (SRGB)
|
||||
0xC1, // 1. QOI_OP_RUN
|
||||
0x79, // 2. QOI_OP_DIFF
|
||||
0xAA, 0x79, // 3. QOI_OP_LUMA
|
||||
0xFE, 0xC8, 0x64, 0x32, // 4. QOI_OP_RGB
|
||||
0xFF, 0x78, 0x50, 0x28,
|
||||
0x64, // 5. QOI_OP_RGBA
|
||||
0x31, // 6. QOI_OP_INDEX
|
||||
0xC1, // 7. QOI_OP_RUN
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // End Marker
|
||||
};
|
||||
|
||||
static const uint8_t QOI_EXPECTED_RGBA[3][3][4] = {
|
||||
{{0x00, 0x00, 0x00, 0xFF}, {0x00, 0x00, 0x00, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}},
|
||||
{{0x0A, 0x0A, 0x0A, 0xFF}, {0xC8, 0x64, 0x32, 0xFF}, {0x78, 0x50, 0x28, 0x64}},
|
||||
{{0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}}
|
||||
|
||||
};
|
||||
|
||||
/// Exposes the protected decoder machinery so reuse and eviction can be observed directly.
|
||||
class TestableRuntimeImage : public RuntimeImage {
|
||||
public:
|
||||
explicit TestableRuntimeImage(ImageFormat format)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
|
||||
explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {}
|
||||
|
||||
ImageDecoder *decoder() { return this->decoder_.get(); }
|
||||
};
|
||||
@@ -132,6 +157,19 @@ template<size_t H, size_t W> static void expect_pixels(TestableRuntimeImage &img
|
||||
}
|
||||
}
|
||||
|
||||
template<size_t H, size_t W>
|
||||
static void expect_pixels_rgba(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][4]) {
|
||||
ASSERT_EQ(img.get_width(), static_cast<int>(W));
|
||||
ASSERT_EQ(img.get_height(), static_cast<int>(H));
|
||||
for (size_t y = 0; y < H; y++) {
|
||||
for (size_t x = 0; x < W; x++) {
|
||||
SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")");
|
||||
Color color = img.get_pixel(x, y);
|
||||
EXPECT_THAT((std::array<uint8_t, 4>{color.r, color.g, color.b, color.w}),
|
||||
::testing::ElementsAreArray(expected[y][x]));
|
||||
}
|
||||
}
|
||||
}
|
||||
TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
@@ -337,6 +375,33 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
}
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
|
||||
TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated";
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, QoiChunkedFeedDecodesLikeDownloadLoop) {
|
||||
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
|
||||
|
||||
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
ImageDecoder *first = img.decoder();
|
||||
|
||||
// Chunked again on the warm decoder: the cross-call resume state
|
||||
// (current_index_ / paint_index_) must have been fully reset.
|
||||
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
std::vector<uint8_t> buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
|
||||
|
||||
@@ -10,12 +10,14 @@ TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
|
||||
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/qoi"), QOI);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-qoi"), QOI);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
|
||||
@@ -39,20 +41,22 @@ TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
|
||||
|
||||
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
|
||||
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
EXPECT_STREQ(get_mime_type_for_format(QOI), "image/qoi");
|
||||
// AUTO has no single MIME type and falls back to the wildcard
|
||||
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
|
||||
|
||||
// Every decodable format must resolve back to itself through its MIME type
|
||||
for (ImageFormat format : {
|
||||
BMP,
|
||||
PNG,
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
JPEG,
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
PNG,
|
||||
QOI,
|
||||
}) {
|
||||
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
|
||||
}
|
||||
|
||||
@@ -80,3 +80,24 @@ switch:
|
||||
- platform: tuya
|
||||
id: tuya_switch
|
||||
switch_datapoint: 1
|
||||
|
||||
water_heater:
|
||||
- platform: tuya
|
||||
id: tuya_water_heater
|
||||
name: Tuya Water Heater
|
||||
switch_datapoint: 1
|
||||
current_temperature_datapoint: 3
|
||||
target_temperature_datapoint: 2
|
||||
current_temperature_multiplier: 0.5
|
||||
target_temperature_multiplier: 0.5
|
||||
mode_datapoint: 4
|
||||
eco_value: 0
|
||||
electric_value: 2
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- ECO
|
||||
- ELECTRIC
|
||||
visual:
|
||||
min_temperature: 30
|
||||
max_temperature: 75
|
||||
target_temperature_step: 1
|
||||
|
||||
@@ -38,3 +38,5 @@ uart_mock:
|
||||
|
||||
improv_serial:
|
||||
uart_id: mock_uart
|
||||
# Deterministic on host: only the device name placeholder is used
|
||||
next_url: https://example.com/?device={{device_name}}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-invert
|
||||
|
||||
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: 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
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
|
||||
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
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
|
||||
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
|
||||
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
|
||||
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -100,7 +100,7 @@ switch:
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
|
||||
# the switch itself resolves to 0x13 (whole registers fold into the address, residual byte stays) and
|
||||
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
|
||||
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
|
||||
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
|
||||
- platform: modbus_controller
|
||||
|
||||
@@ -133,8 +133,15 @@ async def test_improv_serial_uart(
|
||||
)
|
||||
await waiter.wait_for("save_wifi_sta ssid=NewNet")
|
||||
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x04)}")
|
||||
# Settings RPC response with no URLs: payload [0x01, 0x00, 0x00] and footer
|
||||
await waiter.wait_for("uart_mock", "TX 3 bytes: 01:00:00")
|
||||
await waiter.wait_for(
|
||||
"uart_mock", f"TX 2 bytes: {rpc_footer_hex(bytes([0x01, 0x00, 0x00]))}"
|
||||
# Settings RPC response carries the formatted next_url and its footer
|
||||
next_url = b"https://example.com/?device=improv-uart"
|
||||
payload = (
|
||||
bytes([CMD_WIFI_SETTINGS, len(next_url) + 1, len(next_url)])
|
||||
+ next_url
|
||||
+ b"\x00"
|
||||
)
|
||||
await waiter.wait_for(
|
||||
"uart_mock",
|
||||
f"TX {len(payload)} bytes: " + ":".join(f"{b:02X}" for b in payload),
|
||||
)
|
||||
await waiter.wait_for("uart_mock", f"TX 2 bytes: {rpc_footer_hex(payload)}")
|
||||
|
||||
@@ -967,13 +967,6 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="Byte-accurate register-offset writes land in the follow-up offset fix; "
|
||||
"until then the byte offset is folded into the address (writes 0x12 instead of "
|
||||
"0x11). The write and read assertions both flip via the same switch-constructor "
|
||||
"fold. Remove this marker when that change merges.",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_register_offset(
|
||||
yaml_config: str,
|
||||
@@ -1065,6 +1058,61 @@ async def test_uart_mock_modbus_lambda_write(
|
||||
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_invert(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that a write_lambda's return value is the wire value only.
|
||||
|
||||
`invert_switch` is an active-low holding switch whose write_lambda returns !x. Turning it ON must
|
||||
write 0x0000 to the register (observed through the independent reg_40 sensor) while the entity
|
||||
reports ON - the requested state, not the inverted wire value. Turning it OFF writes 0xFFFF and
|
||||
reports OFF. The switch is assumed_state, so the published state comes only from write_state().
|
||||
"""
|
||||
|
||||
tracker = SensorTracker(["reg_40"])
|
||||
initial = tracker.expect("reg_40", 5)
|
||||
wrote_on = tracker.expect("reg_40", 0)
|
||||
wrote_off = tracker.expect("reg_40", 65535)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities = await tracker.setup_and_start_scenario(client)
|
||||
await tracker.await_change(initial, "reg_40", timeout=4.0)
|
||||
|
||||
switch = find_entity(entities, "invert_switch", SwitchInfo)
|
||||
assert switch is not None, "invert_switch not found"
|
||||
|
||||
client.switch_command(switch.key, True)
|
||||
# The wire byte carries the inverted value...
|
||||
await tracker.await_change(wrote_on, "reg_40", timeout=4.0)
|
||||
# ...while the entity reports the requested state. Switch states are deduped, so this relies on
|
||||
# wait_for_state's fresh subscribe_states re-dumping every entity's current state.
|
||||
await wait_for_state(
|
||||
client,
|
||||
lambda s: (
|
||||
getattr(s, "key", None) == switch.key
|
||||
and getattr(s, "state", None) is True
|
||||
),
|
||||
timeout=6.0,
|
||||
)
|
||||
|
||||
client.switch_command(switch.key, False)
|
||||
await tracker.await_change(wrote_off, "reg_40", timeout=4.0)
|
||||
await wait_for_state(
|
||||
client,
|
||||
lambda s: (
|
||||
getattr(s, "key", None) == switch.key
|
||||
and getattr(s, "state", None) is False
|
||||
),
|
||||
timeout=6.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_deprecated_write_buffer(
|
||||
yaml_config: str,
|
||||
|
||||
Reference in New Issue
Block a user