Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-01-02 13:09:46 -10:00
50 changed files with 1187 additions and 224 deletions
+1
View File
@@ -91,6 +91,7 @@ esphome/components/bmp3xx_spi/* @latonita
esphome/components/bmp581/* @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
esphome/components/bytebuffer/* @clydebarrow
esphome/components/camera/* @bdraco @DT-art1
@@ -0,0 +1,36 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MAC_ADDRESS
CODEOWNERS = ["@nagyrobi"]
DEPENDENCIES = ["esp32_ble_tracker"]
BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA
bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer")
BTHomeMiThermometer = bthome_mithermometer_ns.class_(
"BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
def bthome_mithermometer_base_schema(extra_schema=None):
if extra_schema is None:
extra_schema = {}
return (
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
}
)
.extend(BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(extra_schema)
)
async def setup_bthome_mithermometer(var, config):
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
@@ -0,0 +1,298 @@
#include "bthome_ble.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <array>
#ifdef USE_ESP32
namespace esphome {
namespace bthome_mithermometer {
static const char *const TAG = "bthome_mithermometer";
static std::string format_mac_address(uint64_t address) {
std::array<uint8_t, MAC_ADDRESS_SIZE> mac{};
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
mac[i] = (address >> ((MAC_ADDRESS_SIZE - 1 - i) * 8)) & 0xFF;
}
char buffer[MAC_ADDRESS_SIZE * 3];
format_mac_addr_upper(mac.data(), buffer);
return buffer;
}
static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) {
switch (obj_type) {
case 0x00: // packet id
case 0x01: // battery
case 0x09: // count (uint8)
case 0x0F: // generic boolean
case 0x10: // power (bool)
case 0x11: // opening
case 0x15: // battery low
case 0x16: // battery charging
case 0x17: // carbon monoxide
case 0x18: // cold
case 0x19: // connectivity
case 0x1A: // door
case 0x1B: // garage door
case 0x1C: // gas
case 0x1D: // heat
case 0x1E: // light
case 0x1F: // lock
case 0x20: // moisture
case 0x21: // motion
case 0x22: // moving
case 0x23: // occupancy
case 0x24: // plug
case 0x25: // presence
case 0x26: // problem
case 0x27: // running
case 0x28: // safety
case 0x29: // smoke
case 0x2A: // sound
case 0x2B: // tamper
case 0x2C: // vibration
case 0x2D: // water leak
case 0x2E: // humidity (uint8)
case 0x2F: // moisture (uint8)
case 0x46: // UV index
case 0x57: // temperature (sint8)
case 0x58: // temperature (0.35C step)
case 0x59: // count (sint8)
case 0x60: // channel
value_length = 1;
return true;
case 0x02: // temperature (0.01C)
case 0x03: // humidity
case 0x06: // mass (kg)
case 0x07: // mass (lb)
case 0x08: // dewpoint
case 0x0C: // voltage (mV)
case 0x0D: // pm2.5
case 0x0E: // pm10
case 0x12: // CO2
case 0x13: // TVOC
case 0x14: // moisture
case 0x3D: // count (uint16)
case 0x3F: // rotation
case 0x40: // distance (mm)
case 0x41: // distance (m)
case 0x43: // current (A)
case 0x44: // speed
case 0x45: // temperature (0.1C)
case 0x47: // volume (L)
case 0x48: // volume (mL)
case 0x49: // volume flow rate
case 0x4A: // voltage (0.1V)
case 0x51: // acceleration
case 0x52: // gyroscope
case 0x56: // conductivity
case 0x5A: // count (sint16)
case 0x5D: // current (sint16)
case 0x5E: // direction
case 0x5F: // precipitation
case 0x61: // rotational speed
case 0xF0: // button event
value_length = 2;
return true;
case 0x04: // pressure
case 0x05: // illuminance
case 0x0A: // energy
case 0x0B: // power
case 0x42: // duration
case 0x4B: // gas (uint24)
case 0xF2: // firmware version (uint24)
value_length = 3;
return true;
case 0x3E: // count (uint32)
case 0x4C: // gas (uint32)
case 0x4D: // energy (uint32)
case 0x4E: // volume (uint32)
case 0x4F: // water (uint32)
case 0x50: // timestamp
case 0x55: // volume storage
case 0x5B: // count (sint32)
case 0x5C: // power (sint32)
case 0x62: // speed (sint32)
case 0x63: // acceleration (sint32)
case 0xF1: // firmware version (uint32)
value_length = 4;
return true;
default:
return false;
}
}
void BTHomeMiThermometer::dump_config() {
ESP_LOGCONFIG(TAG, "BTHome MiThermometer");
ESP_LOGCONFIG(TAG, " MAC Address: %s", format_mac_address(this->address_).c_str());
LOG_SENSOR(" ", "Temperature", this->temperature_);
LOG_SENSOR(" ", "Humidity", this->humidity_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_);
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_);
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_);
}
bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool matched = false;
for (auto &service_data : device.get_service_datas()) {
if (this->handle_service_data_(service_data, device)) {
matched = true;
}
}
if (matched && this->signal_strength_ != nullptr) {
this->signal_strength_->publish_state(device.get_rssi());
}
return matched;
}
bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data,
const esp32_ble_tracker::ESPBTDevice &device) {
if (!service_data.uuid.contains(0xD2, 0xFC)) {
return false;
}
const auto &data = service_data.data;
if (data.size() < 2) {
ESP_LOGVV(TAG, "BTHome data too short: %zu", data.size());
return false;
}
const uint8_t adv_info = data[0];
const bool is_encrypted = adv_info & 0x01;
const bool mac_included = adv_info & 0x02;
const bool is_trigger_based = adv_info & 0x04;
const uint8_t version = (adv_info >> 5) & 0x07;
if (version != 0x02) {
ESP_LOGVV(TAG, "Unsupported BTHome version %u", version);
return false;
}
if (is_encrypted) {
ESP_LOGV(TAG, "Ignoring encrypted BTHome frame from %s", device.address_str().c_str());
return false;
}
size_t payload_index = 1;
uint64_t source_address = device.address_uint64();
if (mac_included) {
if (data.size() < 7) {
ESP_LOGVV(TAG, "BTHome payload missing MAC address");
return false;
}
source_address = 0;
for (int i = 5; i >= 0; i--) {
source_address = (source_address << 8) | data[1 + i];
}
payload_index = 7;
}
if (source_address != this->address_) {
ESP_LOGVV(TAG, "BTHome frame from unexpected device %s", format_mac_address(source_address).c_str());
return false;
}
if (payload_index >= data.size()) {
ESP_LOGVV(TAG, "BTHome payload empty after header");
return false;
}
bool reported = false;
size_t offset = payload_index;
uint8_t last_type = 0;
while (offset < data.size()) {
const uint8_t obj_type = data[offset++];
size_t value_length = 0;
bool has_length_byte = obj_type == 0x53; // text objects include explicit length
if (has_length_byte) {
if (offset >= data.size()) {
break;
}
value_length = data[offset++];
} else {
if (!get_bthome_value_length(obj_type, value_length)) {
ESP_LOGVV(TAG, "Unknown BTHome object 0x%02X", obj_type);
break;
}
}
if (value_length == 0) {
break;
}
if (offset + value_length > data.size()) {
ESP_LOGVV(TAG, "BTHome object length exceeds payload");
break;
}
const uint8_t *value = &data[offset];
offset += value_length;
if (obj_type < last_type) {
ESP_LOGVV(TAG, "BTHome objects not in ascending order");
}
last_type = obj_type;
switch (obj_type) {
case 0x00: { // packet id
const uint8_t packet_id = value[0];
if (this->last_packet_id_.has_value() && *this->last_packet_id_ == packet_id) {
return reported;
}
this->last_packet_id_ = packet_id;
break;
}
case 0x01: { // battery percentage
if (this->battery_level_ != nullptr) {
this->battery_level_->publish_state(value[0]);
reported = true;
}
break;
}
case 0x0C: { // battery voltage (mV)
if (this->battery_voltage_ != nullptr) {
const uint16_t raw = encode_uint16(value[1], value[0]);
this->battery_voltage_->publish_state(raw * 0.001f);
reported = true;
}
break;
}
case 0x02: { // temperature
if (this->temperature_ != nullptr) {
const int16_t raw = encode_uint16(value[1], value[0]);
this->temperature_->publish_state(raw * 0.01f);
reported = true;
}
break;
}
case 0x03: { // humidity
if (this->humidity_ != nullptr) {
const uint16_t raw = encode_uint16(value[1], value[0]);
this->humidity_->publish_state(raw * 0.01f);
reported = true;
}
break;
}
default:
break;
}
}
if (reported) {
ESP_LOGD(TAG, "BTHome data%sfrom %s", is_trigger_based ? " (triggered) " : " ", device.address_str().c_str());
}
return reported;
}
} // namespace bthome_mithermometer
} // namespace esphome
#endif
@@ -0,0 +1,44 @@
#pragma once
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include <cstdint>
#ifdef USE_ESP32
namespace esphome {
namespace bthome_mithermometer {
class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component {
public:
void set_address(uint64_t address) { this->address_ = address; }
void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; }
void set_humidity(sensor::Sensor *humidity) { this->humidity_ = humidity; }
void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; }
void set_battery_voltage(sensor::Sensor *battery_voltage) { this->battery_voltage_ = battery_voltage; }
void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; }
void dump_config() override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
protected:
bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data,
const esp32_ble_tracker::ESPBTDevice &device);
uint64_t address_{0};
optional<uint8_t> last_packet_id_{};
sensor::Sensor *temperature_{nullptr};
sensor::Sensor *humidity_{nullptr};
sensor::Sensor *battery_level_{nullptr};
sensor::Sensor *battery_voltage_{nullptr};
sensor::Sensor *signal_strength_{nullptr};
};
} // namespace bthome_mithermometer
} // namespace esphome
#endif
@@ -0,0 +1,88 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_HUMIDITY,
CONF_ID,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_DECIBEL_MILLIWATT,
UNIT_PERCENT,
UNIT_VOLT,
)
from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer
CODEOWNERS = ["@nagyrobi"]
DEPENDENCIES = ["esp32_ble_tracker"]
CONFIG_SCHEMA = bthome_mithermometer_base_schema(
{
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=2,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
icon="mdi:battery-plus",
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema(
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await setup_bthome_mithermometer(var, config)
if temp_sens := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temp_sens)
cg.add(var.set_temperature(sens))
if humi_sens := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humi_sens)
cg.add(var.set_humidity(sens))
if batl_sens := config.get(CONF_BATTERY_LEVEL):
sens = await sensor.new_sensor(batl_sens)
cg.add(var.set_battery_level(sens))
if batv_sens := config.get(CONF_BATTERY_VOLTAGE):
sens = await sensor.new_sensor(batv_sens)
cg.add(var.set_battery_voltage(sens))
if sgnl_sens := config.get(CONF_SIGNAL_STRENGTH):
sens = await sensor.new_sensor(sgnl_sens)
cg.add(var.set_signal_strength(sens))
@@ -76,6 +76,12 @@ class EPaperBase : public Display,
return 0;
}
void fill(Color color) override {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
auto pixel_color = color_to_bit(color) ? 0xFF : 0x00;
// We store 8 pixels per byte
@@ -97,6 +97,12 @@ void EPaperSpectraE6::deep_sleep() {
}
void EPaperSpectraE6::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
auto pixel_color = color_to_hex(color);
// We store 2 pixels per byte
@@ -131,6 +131,13 @@ float ILI9XXXDisplay::get_setup_priority() const { return setup_priority::HARDWA
void ILI9XXXDisplay::fill(Color color) {
if (!this->check_buffer_())
return;
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
uint16_t new_color = 0;
this->x_low_ = 0;
this->y_low_ = 0;
+7
View File
@@ -293,6 +293,13 @@ void Inkplate::fill(Color color) {
ESP_LOGV(TAG, "Fill called");
uint32_t start_time = millis();
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time);
return;
}
if (this->greyscale_) {
uint8_t fill = ((color.red * 2126 / 10000) + (color.green * 7152 / 10000) + (color.blue * 722 / 10000)) >> 5;
memset(this->buffer_, (fill << 4) | fill, this->get_buffer_length_());
+7
View File
@@ -300,6 +300,13 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) {
void MIPI_DSI::fill(Color color) {
if (!this->check_buffer_())
return;
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
switch (this->color_depth_) {
case display::COLOR_BITNESS_565: {
auto *ptr_16 = reinterpret_cast<uint16_t *>(this->buffer_);
+7
View File
@@ -305,6 +305,13 @@ void MipiRgb::draw_pixel_at(int x, int y, Color color) {
void MipiRgb::fill(Color color) {
if (!this->check_buffer_())
return;
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
auto *ptr_16 = reinterpret_cast<uint16_t *>(this->buffer_);
uint8_t hi_byte = static_cast<uint8_t>(color.r & 0xF8) | (color.g >> 5);
uint8_t lo_byte = static_cast<uint8_t>((color.g & 0x1C) << 3) | (color.b >> 3);
+7 -1
View File
@@ -247,8 +247,8 @@ class MipiSpi : public display::Display,
void write_command_(uint8_t cmd, const uint8_t *bytes, size_t len) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)];
#endif
esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len));
#endif
if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
this->enable();
this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len);
@@ -569,6 +569,12 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
// Fills the display with a color.
void fill(Color color) override {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
display::Display::fill(color);
return;
}
this->x_low_ = 0;
this->y_low_ = this->start_line_;
this->x_high_ = WIDTH - 1;
+6
View File
@@ -117,6 +117,12 @@ void PCD8544::update() {
}
void PCD8544::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
uint8_t fill = color.is_on() ? 0xFF : 0x00;
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
+1 -1
View File
@@ -70,7 +70,7 @@ void SN74HC595GPIOComponent::write_gpio() {
void SN74HC595SPIComponent::write_gpio() {
for (uint8_t &output_byte : std::ranges::reverse_view(this->output_bytes_)) {
this->enable();
this->transfer_byte(output_byte);
this->write_byte(output_byte);
this->disable();
}
SN74HC595Component::write_gpio();
+67 -27
View File
@@ -49,21 +49,60 @@ SPIDevice = spi_ns.class_("SPIDevice")
SPIDataRate = spi_ns.enum("SPIDataRate")
SPIMode = spi_ns.enum("SPIMode")
SPI_DATA_RATE_OPTIONS = {
80e6: SPIDataRate.DATA_RATE_80MHZ,
40e6: SPIDataRate.DATA_RATE_40MHZ,
20e6: SPIDataRate.DATA_RATE_20MHZ,
10e6: SPIDataRate.DATA_RATE_10MHZ,
8e6: SPIDataRate.DATA_RATE_8MHZ,
5e6: SPIDataRate.DATA_RATE_5MHZ,
4e6: SPIDataRate.DATA_RATE_4MHZ,
2e6: SPIDataRate.DATA_RATE_2MHZ,
1e6: SPIDataRate.DATA_RATE_1MHZ,
2e5: SPIDataRate.DATA_RATE_200KHZ,
75e3: SPIDataRate.DATA_RATE_75KHZ,
1e3: SPIDataRate.DATA_RATE_1KHZ,
PLATFORM_SPI_CLOCKS = {
PLATFORM_ESP8266: 40e6,
PLATFORM_ESP32: 80e6,
PLATFORM_RP2040: 62.5e6,
}
SPI_DATA_RATE_SCHEMA = cv.All(cv.frequency, cv.enum(SPI_DATA_RATE_OPTIONS))
MAX_DATA_RATE_ERROR = 0.05 # Max allowable actual data rate difference from requested
def _render_hz(value: float) -> str:
"""Render a frequency in Hz as a human-readable string using Hz, KHz or MHz.
Examples:
500 -> "500 Hz"
1500 -> "1.5 kHz"
2000000 -> "2 MHz"
"""
if value >= 1e6:
unit = "MHz"
num = value / 1e6
elif value >= 1e3:
unit = "kHz"
num = value / 1e3
else:
unit = "Hz"
num = value
# Format with up to 2 decimal places, then strip unnecessary trailing zeros and dot
formatted = f"{int(num)}" if unit == "Hz" else f"{num:.2f}".rstrip("0").rstrip(".")
return formatted + unit
def _frequency_validator(value):
platform = get_target_platform()
frequency = PLATFORM_SPI_CLOCKS[platform]
value = cv.frequency(value)
if value > frequency:
raise cv.Invalid(
f"The configured SPI data rate ({_render_hz(value)}) exceeds the maximum for this platform ({_render_hz(frequency)})"
)
if value < 1000:
raise cv.Invalid("The configured SPI data rate must be at least 1000Hz")
divisor = round(frequency / value)
actual = frequency / divisor
error = abs(actual - value) / value
if error > MAX_DATA_RATE_ERROR:
raise cv.Invalid(
f"The configured SPI data rate ({_render_hz(value)}) is not available for this chip - closest is {_render_hz(actual)}"
)
return value
SPI_DATA_RATE_SCHEMA = _frequency_validator
SPI_MODE_OPTIONS = {
"MODE0": SPIMode.MODE0,
@@ -393,19 +432,20 @@ def spi_device_schema(
:param mode Choose single, quad or octal mode.
:return: The SPI device schema, `extend` this in your config schema.
"""
schema = {
cv.GenerateID(CONF_SPI_ID): cv.use_id(TYPE_CLASS[mode]),
cv.Optional(CONF_DATA_RATE, default=default_data_rate): SPI_DATA_RATE_SCHEMA,
cv.Optional(CONF_SPI_MODE, default=default_mode): cv.enum(
SPI_MODE_OPTIONS, upper=True
),
cv.Optional(CONF_RELEASE_DEVICE): cv.All(cv.boolean, cv.only_on_esp32),
}
if cs_pin_required:
schema[cv.Required(CONF_CS_PIN)] = pins.gpio_output_pin_schema
else:
schema[cv.Optional(CONF_CS_PIN)] = pins.gpio_output_pin_schema
return cv.Schema(schema)
cs_pin_option = cv.Required if cs_pin_required else cv.Optional
return cv.Schema(
{
cv.GenerateID(CONF_SPI_ID): cv.use_id(TYPE_CLASS[mode]),
cv.Optional(
CONF_DATA_RATE, default=default_data_rate
): SPI_DATA_RATE_SCHEMA,
cv.Optional(CONF_SPI_MODE, default=default_mode): cv.enum(
SPI_MODE_OPTIONS, upper=True
),
cv.Optional(CONF_RELEASE_DEVICE): cv.All(cv.boolean, cv.only_on_esp32),
cs_pin_option(CONF_CS_PIN): pins.gpio_output_pin_schema,
}
)
async def register_spi_device(var, config):
@@ -329,6 +329,12 @@ void HOT SSD1306::draw_absolute_pixel_internal(int x, int y, Color color) {
}
}
void SSD1306::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
uint8_t fill = color.is_on() ? 0xFF : 0x00;
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
this->buffer_[i] = fill;
@@ -174,6 +174,12 @@ void HOT SSD1322::draw_absolute_pixel_internal(int x, int y, Color color) {
this->buffer_[pos] |= color4;
}
void SSD1322::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color);
uint8_t fill = (color4 & SSD1322_COLORMASK) | ((color4 & SSD1322_COLORMASK) << SSD1322_COLORSHIFT);
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
@@ -150,6 +150,12 @@ void HOT SSD1327::draw_absolute_pixel_internal(int x, int y, Color color) {
this->buffer_[pos] |= color4;
}
void SSD1327::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color);
uint8_t fill = (color4 & SSD1327_COLORMASK) | ((color4 & SSD1327_COLORMASK) << SSD1327_COLORSHIFT);
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
@@ -128,6 +128,12 @@ void HOT SSD1331::draw_absolute_pixel_internal(int x, int y, Color color) {
this->buffer_[pos] = color565 & 0xff;
}
void SSD1331::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
const uint32_t color565 = display::ColorUtil::color_to_565(color);
for (uint32_t i = 0; i < this->get_buffer_length_(); i++) {
if (i & 1) {
@@ -160,6 +160,12 @@ void HOT SSD1351::draw_absolute_pixel_internal(int x, int y, Color color) {
this->buffer_[pos] = color565 & 0xff;
}
void SSD1351::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
const uint32_t color565 = display::ColorUtil::color_to_565(color);
for (uint32_t i = 0; i < this->get_buffer_length_(); i++) {
if (i & 1) {
+10 -1
View File
@@ -131,7 +131,16 @@ void HOT ST7567::draw_absolute_pixel_internal(int x, int y, Color color) {
}
}
void ST7567::fill(Color color) { memset(buffer_, color.is_on() ? 0xFF : 0x00, this->get_buffer_length_()); }
void ST7567::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
uint8_t fill = color.is_on() ? 0xFF : 0x00;
memset(buffer_, fill, this->get_buffer_length_());
}
void ST7567::init_reset_() {
if (this->reset_pin_ != nullptr) {
+10 -1
View File
@@ -89,7 +89,16 @@ void HOT ST7920::write_display_data() {
}
}
void ST7920::fill(Color color) { memset(this->buffer_, color.is_on() ? 0xFF : 0x00, this->get_buffer_length_()); }
void ST7920::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
uint8_t fill = color.is_on() ? 0xFF : 0x00;
memset(this->buffer_, fill, this->get_buffer_length_());
}
void ST7920::dump_config() {
LOG_DISPLAY("", "ST7920", this);
+1 -1
View File
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
)
.extend(
{
cv.Required(CONF_TRIGGER_PIN): pins.gpio_output_pin_schema,
cv.Required(CONF_TRIGGER_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_ECHO_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_TIMEOUT, default="2m"): cv.distance,
cv.Optional(
@@ -1,64 +1,96 @@
#include "ultrasonic_sensor.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome {
namespace ultrasonic {
namespace esphome::ultrasonic {
static const char *const TAG = "ultrasonic.sensor";
static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us (noise filtering)
static constexpr uint32_t TIMEOUT_MARGIN_US = 1000; // Extra margin for sensor processing time
void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) {
uint32_t now = micros();
if (!arg->echo_start || (now - arg->echo_start_us) <= DEBOUNCE_US) {
arg->echo_start_us = now;
arg->echo_start = true;
} else {
arg->echo_end_us = now;
arg->echo_end = true;
}
}
void IRAM_ATTR UltrasonicSensorComponent::send_trigger_pulse_() {
InterruptLock lock;
this->store_.echo_start_us = 0;
this->store_.echo_end_us = 0;
this->store_.echo_start = false;
this->store_.echo_end = false;
this->trigger_pin_isr_.digital_write(true);
delayMicroseconds(this->pulse_time_us_);
this->trigger_pin_isr_.digital_write(false);
this->measurement_pending_ = true;
this->measurement_start_us_ = micros();
}
void UltrasonicSensorComponent::setup() {
this->trigger_pin_->setup();
this->trigger_pin_->digital_write(false);
this->trigger_pin_isr_ = this->trigger_pin_->to_isr();
this->echo_pin_->setup();
// isr is faster to access
echo_isr_ = echo_pin_->to_isr();
this->echo_pin_->attach_interrupt(UltrasonicSensorStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE);
}
void UltrasonicSensorComponent::update() {
this->trigger_pin_->digital_write(true);
delayMicroseconds(this->pulse_time_us_);
this->trigger_pin_->digital_write(false);
if (this->measurement_pending_) {
return;
}
this->send_trigger_pulse_();
}
const uint32_t start = micros();
while (micros() - start < timeout_us_ && echo_isr_.digital_read())
;
while (micros() - start < timeout_us_ && !echo_isr_.digital_read())
;
const uint32_t pulse_start = micros();
while (micros() - start < timeout_us_ && echo_isr_.digital_read())
;
const uint32_t pulse_end = micros();
void UltrasonicSensorComponent::loop() {
if (!this->measurement_pending_) {
return;
}
ESP_LOGV(TAG, "Echo took %" PRIu32 "µs", pulse_end - pulse_start);
if (pulse_end - start >= timeout_us_) {
ESP_LOGD(TAG, "'%s' - Distance measurement timed out!", this->name_.c_str());
this->publish_state(NAN);
} else {
float result = UltrasonicSensorComponent::us_to_m(pulse_end - pulse_start);
if (this->store_.echo_end) {
uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us;
ESP_LOGV(TAG, "Echo took %" PRIu32 "us", pulse_duration);
float result = UltrasonicSensorComponent::us_to_m(pulse_duration);
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result);
this->publish_state(result);
this->measurement_pending_ = false;
return;
}
uint32_t elapsed = micros() - this->measurement_start_us_;
if (elapsed >= this->timeout_us_ + TIMEOUT_MARGIN_US) {
ESP_LOGD(TAG,
"'%s' - Timeout after %" PRIu32 "us (measurement_start=%" PRIu32 ", echo_start=%" PRIu32
", echo_end=%" PRIu32 ")",
this->name_.c_str(), elapsed, this->measurement_start_us_, this->store_.echo_start_us,
this->store_.echo_end_us);
this->publish_state(NAN);
this->measurement_pending_ = false;
}
}
void UltrasonicSensorComponent::dump_config() {
LOG_SENSOR("", "Ultrasonic Sensor", this);
LOG_PIN(" Echo Pin: ", this->echo_pin_);
LOG_PIN(" Trigger Pin: ", this->trigger_pin_);
ESP_LOGCONFIG(TAG,
" Pulse time: %" PRIu32 " µs\n"
" Timeout: %" PRIu32 " µs",
" Pulse time: %" PRIu32 " us\n"
" Timeout: %" PRIu32 " us",
this->pulse_time_us_, this->timeout_us_);
LOG_UPDATE_INTERVAL(this);
}
float UltrasonicSensorComponent::us_to_m(uint32_t us) {
const float speed_sound_m_per_s = 343.0f;
const float time_s = us / 1e6f;
const float total_dist = time_s * speed_sound_m_per_s;
return total_dist / 2.0f;
}
float UltrasonicSensorComponent::get_setup_priority() const { return setup_priority::DATA; }
void UltrasonicSensorComponent::set_pulse_time_us(uint32_t pulse_time_us) { this->pulse_time_us_ = pulse_time_us; }
void UltrasonicSensorComponent::set_timeout_us(uint32_t timeout_us) { this->timeout_us_ = timeout_us; }
} // namespace ultrasonic
} // namespace esphome
} // namespace esphome::ultrasonic
@@ -6,41 +6,49 @@
#include <cinttypes>
namespace esphome {
namespace ultrasonic {
namespace esphome::ultrasonic {
struct UltrasonicSensorStore {
static void gpio_intr(UltrasonicSensorStore *arg);
volatile uint32_t echo_start_us{0};
volatile uint32_t echo_end_us{0};
volatile bool echo_start{false};
volatile bool echo_end{false};
};
class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent {
public:
void set_trigger_pin(GPIOPin *trigger_pin) { trigger_pin_ = trigger_pin; }
void set_echo_pin(InternalGPIOPin *echo_pin) { echo_pin_ = echo_pin; }
void set_trigger_pin(InternalGPIOPin *trigger_pin) { this->trigger_pin_ = trigger_pin; }
void set_echo_pin(InternalGPIOPin *echo_pin) { this->echo_pin_ = echo_pin; }
/// Set the timeout for waiting for the echo in µs.
void set_timeout_us(uint32_t timeout_us);
void set_timeout_us(uint32_t timeout_us) { this->timeout_us_ = timeout_us; }
// ========== INTERNAL METHODS ==========
// (In most use cases you won't need these)
/// Set up pins and register interval.
void setup() override;
void loop() override;
void dump_config() override;
void update() override;
float get_setup_priority() const override;
float get_setup_priority() const override { return setup_priority::DATA; }
/// Set the time in µs the trigger pin should be enabled for in µs, defaults to 10µs (for HC-SR04)
void set_pulse_time_us(uint32_t pulse_time_us);
void set_pulse_time_us(uint32_t pulse_time_us) { this->pulse_time_us_ = pulse_time_us; }
protected:
/// Helper function to convert the specified echo duration in µs to meters.
static float us_to_m(uint32_t us);
/// Helper function to convert the specified distance in meters to the echo duration in µs.
void send_trigger_pulse_();
GPIOPin *trigger_pin_;
InternalGPIOPin *trigger_pin_;
ISRInternalGPIOPin trigger_pin_isr_;
InternalGPIOPin *echo_pin_;
ISRInternalGPIOPin echo_isr_;
uint32_t timeout_us_{}; /// 2 meters.
UltrasonicSensorStore store_;
uint32_t timeout_us_{};
uint32_t pulse_time_us_{};
uint32_t measurement_start_us_{0};
bool measurement_pending_{false};
};
} // namespace ultrasonic
} // namespace esphome
} // namespace esphome::ultrasonic
@@ -172,6 +172,12 @@ void WaveshareEPaperBase::update() {
this->display();
}
void WaveshareEPaper::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
// flip logic
const uint8_t fill = color.is_on() ? 0x00 : 0xFF;
for (uint32_t i = 0; i < this->get_buffer_length_(); i++)
@@ -234,6 +240,12 @@ uint8_t WaveshareEPaper7C::color_to_hex(Color color) {
return hex_code;
}
void WaveshareEPaper7C::fill(Color color) {
// If clipping is active, use base class (3-bit packing is complex for partial fills)
if (this->get_clipping().is_set()) {
display::Display::fill(color);
return;
}
uint8_t pixel_color;
if (color.is_on()) {
pixel_color = this->color_to_hex(color);
+228 -87
View File
@@ -45,62 +45,144 @@ static constexpr size_t PSTR_LOCAL_SIZE = 18;
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
// Parse URL and return match info
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) {
UrlMatch match{};
// URL formats (disambiguated by HTTP method for 3-segment case):
// GET /{domain}/{entity_name} - main device state
// POST /{domain}/{entity_name}/{action} - main device action
// GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only)
// POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only)
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) {
// URL must start with '/' and have content after it
if (url_len < 2 || url_ptr[0] != '/')
return UrlMatch{};
// URL must start with '/'
if (url_len < 2 || url_ptr[0] != '/') {
return match;
}
// Skip leading '/'
const char *start = url_ptr + 1;
const char *p = url_ptr + 1;
const char *end = url_ptr + url_len;
// Find domain (everything up to next '/' or end)
const char *domain_end = (const char *) memchr(start, '/', end - start);
if (!domain_end) {
// No second slash found - original behavior returns invalid
return match;
}
// Helper to find next segment: returns pointer after '/' or nullptr if no more slashes
auto next_segment = [&end](const char *start) -> const char * {
const char *slash = (const char *) memchr(start, '/', end - start);
return slash ? slash + 1 : nullptr;
};
// Set domain
match.domain = start;
match.domain_len = domain_end - start;
// Helper to make StringRef from segment start to next segment (or end)
auto make_ref = [&end](const char *start, const char *next_start) -> StringRef {
return StringRef(start, (next_start ? next_start - 1 : end) - start);
};
// Parse domain segment
const char *s1 = p;
const char *s2 = next_segment(s1);
// Must have domain with trailing slash
if (!s2)
return UrlMatch{};
UrlMatch match{};
match.domain = make_ref(s1, s2);
match.valid = true;
if (only_domain) {
if (only_domain || s2 >= end)
return match;
}
// Parse ID if present
if (domain_end + 1 >= end) {
return match; // Nothing after domain slash
}
// Parse remaining segments only when needed
const char *s3 = next_segment(s2);
const char *s4 = s3 ? next_segment(s3) : nullptr;
const char *id_start = domain_end + 1;
const char *id_end = (const char *) memchr(id_start, '/', end - id_start);
StringRef seg2 = make_ref(s2, s3);
StringRef seg3 = s3 ? make_ref(s3, s4) : StringRef();
StringRef seg4 = s4 ? make_ref(s4, nullptr) : StringRef();
if (!id_end) {
// No more slashes, entire remaining string is ID
match.id = id_start;
match.id_len = end - id_start;
return match;
}
// Reject empty segments
if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty()))
return UrlMatch{};
// Set ID
match.id = id_start;
match.id_len = id_end - id_start;
// Parse method if present
if (id_end + 1 < end) {
match.method = id_end + 1;
match.method_len = end - (id_end + 1);
// Interpret based on segment count
if (!s3) {
// 1 segment after domain: /{domain}/{entity}
match.id = seg2;
} else if (!s4) {
// 2 segments after domain: /{domain}/{X}/{Y}
// HTTP method disambiguates: GET = device/entity, POST = entity/action
if (is_post) {
match.id = seg2;
match.method = seg3;
return match;
}
#ifdef USE_DEVICES
match.device_name = seg2;
match.id = seg3;
#else
return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES
#endif
} else {
// 3 segments after domain: /{domain}/{device}/{entity}/{action}
#ifdef USE_DEVICES
if (!is_post) {
return UrlMatch{}; // 4-segment GET not supported (action requires POST)
}
match.device_name = seg2;
match.id = seg3;
match.method = seg4;
#else
return UrlMatch{}; // Not supported without USE_DEVICES
#endif
}
return match;
}
EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
EntityMatchResult result{false, this->method.empty()};
#ifdef USE_DEVICES
Device *entity_device = entity->get_device();
bool url_has_device = !this->device_name.empty();
bool entity_has_device = (entity_device != nullptr);
// Device matching: URL device segment must match entity's device
if (url_has_device != entity_has_device) {
return result; // Mismatch: one has device, other doesn't
}
if (url_has_device && this->device_name != entity_device->get_name()) {
return result; // Device name doesn't match
}
#endif
// Try matching by entity name (new format)
if (this->id == entity->get_name()) {
result.matched = true;
return result;
}
// Fall back to object_id (deprecated format)
char object_id_buf[OBJECT_ID_MAX_LEN];
StringRef object_id = entity->get_object_id_to(object_id_buf);
if (this->id == object_id) {
result.matched = true;
// Log deprecation warning
#ifdef USE_DEVICES
Device *device = entity->get_device();
if (device != nullptr) {
ESP_LOGW(TAG,
"Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. "
"Object ID URLs will be removed in 2026.7.0.",
(int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(),
this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(),
this->domain.c_str(), device->get_name(), entity->get_name().c_str());
} else
#endif
{
ESP_LOGW(TAG,
"Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. "
"Object ID URLs will be removed in 2026.7.0.",
(int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(),
(int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str());
}
}
return result;
}
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
// helper for allowing only unique entries in the queue
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
@@ -397,15 +479,53 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) {
#endif
// Helper functions to reduce code size by avoiding macro expansion
// Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name}
// Uses names (not object_id) to avoid UTF-8 collision issues
static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) {
char id_buf[160]; // prefix + dash + object_id (up to 128) + null
size_t len = strlen(prefix);
memcpy(id_buf, prefix, len); // NOLINT(bugprone-not-null-terminated-result) - null added by write_object_id_to
id_buf[len++] = '-';
obj->write_object_id_to(id_buf + len, sizeof(id_buf) - len);
const StringRef &name = obj->get_name();
size_t prefix_len = strlen(prefix);
size_t name_len = name.size();
#ifdef USE_DEVICES
Device *device = obj->get_device();
const char *device_name = device ? device->get_name() : nullptr;
size_t device_len = device_name ? strlen(device_name) : 0;
#endif
// Build id into stack buffer - ArduinoJson copies the string
// Format: {prefix}/{device?}/{name}
// Buffer size guaranteed by schema validation (NAME_MAX_LENGTH=120):
// With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263, rounded up to 280 for safety margin
// Without devices: domain(20) + "/" + name(120) + null = 142, rounded up to 150 for safety margin
#ifdef USE_DEVICES
char id_buf[280];
#else
char id_buf[150];
#endif
char *p = id_buf;
memcpy(p, prefix, prefix_len);
p += prefix_len;
*p++ = '/';
#ifdef USE_DEVICES
if (device_name) {
memcpy(p, device_name, device_len);
p += device_len;
*p++ = '/';
}
#endif
memcpy(p, name.c_str(), name_len);
p[name_len] = '\0';
root[ESPHOME_F("id")] = id_buf;
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("name")] = obj->get_name();
root[ESPHOME_F("domain")] = prefix;
root[ESPHOME_F("name")] = name;
#ifdef USE_DEVICES
if (device_name) {
root[ESPHOME_F("device")] = device_name;
}
#endif
root[ESPHOME_F("icon")] = obj->get_icon_ref();
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
bool is_disabled = obj->is_disabled_by_default();
@@ -444,10 +564,11 @@ void WebServer::on_sensor_update(sensor::Sensor *obj) {
}
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (sensor::Sensor *obj : App.get_sensors()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->sensor_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -490,10 +611,11 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) {
}
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (text_sensor::TextSensor *obj : App.get_text_sensors()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->text_sensor_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -532,10 +654,11 @@ void WebServer::on_switch_update(switch_::Switch *obj) {
}
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (switch_::Switch *obj : App.get_switches()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->switch_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -601,9 +724,10 @@ std::string WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail
#ifdef USE_BUTTON
void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (button::Button *obj : App.get_buttons()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->button_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -645,10 +769,11 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
}
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->binary_sensor_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -686,10 +811,11 @@ void WebServer::on_fan_update(fan::Fan *obj) {
}
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (fan::Fan *obj : App.get_fans()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->fan_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -766,10 +892,11 @@ void WebServer::on_light_update(light::LightState *obj) {
}
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (light::LightState *obj : App.get_lights()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->light_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -844,10 +971,11 @@ void WebServer::on_cover_update(cover::Cover *obj) {
}
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (cover::Cover *obj : App.get_covers()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->cover_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -932,10 +1060,11 @@ void WebServer::on_number_update(number::Number *obj) {
}
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_numbers()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->number_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -999,9 +1128,10 @@ void WebServer::on_date_update(datetime::DateEntity *obj) {
}
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_dates()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->date_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1062,9 +1192,10 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) {
}
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_times()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->time_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1124,9 +1255,10 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
}
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_datetimes()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->datetime_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1188,10 +1320,11 @@ void WebServer::on_text_update(text::Text *obj) {
}
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_texts()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->text_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -1244,10 +1377,11 @@ void WebServer::on_select_update(select::Select *obj) {
}
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_selects()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->select_json_(obj, obj->has_state() ? obj->current_option() : "", detail);
request->send(200, "application/json", data.c_str());
@@ -1301,10 +1435,11 @@ void WebServer::on_climate_update(climate::Climate *obj) {
}
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_climates()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->climate_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1451,10 +1586,11 @@ void WebServer::on_lock_update(lock::Lock *obj) {
}
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (lock::Lock *obj : App.get_locks()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->lock_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -1525,10 +1661,11 @@ void WebServer::on_valve_update(valve::Valve *obj) {
}
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (valve::Valve *obj : App.get_valves()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->valve_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1609,10 +1746,11 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP
}
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->alarm_control_panel_json_(obj, obj->get_state(), detail);
request->send(200, "application/json", data.c_str());
@@ -1690,11 +1828,12 @@ void WebServer::on_event(event::Event *obj) {
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (event::Event *obj : App.get_events()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->event_json_(obj, "", detail);
request->send(200, "application/json", data.c_str());
@@ -1759,10 +1898,11 @@ void WebServer::on_update(update::UpdateEntity *obj) {
}
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (update::UpdateEntity *obj : App.get_updates()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->update_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1973,7 +2113,8 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
#endif
// Parse URL for component routing
UrlMatch match = match_url(url.c_str(), url.length(), false);
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
// Route to appropriate handler based on domain
// NOLINTNEXTLINE(readability-simplify-boolean-expr)
+18 -22
View File
@@ -35,33 +35,29 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE;
namespace esphome::web_server {
/// Result of matching a URL against an entity
struct EntityMatchResult {
bool matched; ///< True if entity matched the URL
bool action_is_empty; ///< True if no action/method segment in URL
};
/// Internal helper struct that is used to parse incoming URLs
struct UrlMatch {
const char *domain; ///< Pointer to domain within URL, for example "sensor"
const char *id; ///< Pointer to id within URL, for example "living_room_fan"
const char *method; ///< Pointer to method within URL, for example "turn_on"
uint8_t domain_len; ///< Length of domain string
uint8_t id_len; ///< Length of id string
uint8_t method_len; ///< Length of method string
bool valid; ///< Whether this match is valid
StringRef domain; ///< Domain within URL, for example "sensor"
StringRef id; ///< Entity name/id within URL, for example "Temperature"
StringRef method; ///< Method within URL, for example "turn_on"
#ifdef USE_DEVICES
StringRef device_name; ///< Device name within URL, empty for main device
#endif
bool valid{false}; ///< Whether this match is valid
// Helper methods for string comparisons
bool domain_equals(const char *str) const {
return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0;
}
bool domain_equals(const char *str) const { return this->domain == str; }
bool method_equals(const char *str) const { return this->method == str; }
bool id_equals_entity(EntityBase *entity) const {
// Get object_id with zero heap allocation
char object_id_buf[OBJECT_ID_MAX_LEN];
StringRef object_id = entity->get_object_id_to(object_id_buf);
return id && id_len == object_id.size() && memcmp(id, object_id.c_str(), id_len) == 0;
}
bool method_equals(const char *str) const {
return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0;
}
bool method_empty() const { return method_len == 0; }
/// Match entity by name first, then fall back to object_id with deprecation warning
/// Returns EntityMatchResult with match status and whether action segment is empty
EntityMatchResult match_entity(EntityBase *entity) const;
};
#ifdef USE_WEBSERVER_SORTING
@@ -5,6 +5,29 @@
namespace esphome::web_server {
// Write HTML-escaped text to stream (escapes ", &, <, >)
static void write_html_escaped(AsyncResponseStream *stream, const char *text) {
for (const char *p = text; *p; ++p) {
switch (*p) {
case '"':
stream->print("&quot;");
break;
case '&':
stream->print("&amp;");
break;
case '<':
stream->print("&lt;");
break;
case '>':
stream->print("&gt;");
break;
default:
stream->write(*p);
break;
}
}
}
void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &klass, const std::string &action,
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
stream->print("<tr class=\"");
@@ -16,8 +39,27 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &
stream->print("-");
char object_id_buf[OBJECT_ID_MAX_LEN];
stream->print(obj->get_object_id_to(object_id_buf).c_str());
// Add data attributes for hierarchical URL support
stream->print("\" data-domain=\"");
stream->print(klass.c_str());
stream->print("\" data-name=\"");
write_html_escaped(stream, obj->get_name().c_str());
#ifdef USE_DEVICES
Device *device = obj->get_device();
if (device != nullptr) {
stream->print("\" data-device=\"");
write_html_escaped(stream, device->get_name());
}
#endif
stream->print("\"><td>");
stream->print(obj->get_name().c_str());
#ifdef USE_DEVICES
if (device != nullptr) {
stream->print("[");
write_html_escaped(stream, device->get_name());
stream->print("] ");
}
#endif
write_html_escaped(stream, obj->get_name().c_str());
stream->print("</td><td></td><td>");
stream->print(action.c_str());
if (action_func) {
+4 -2
View File
@@ -13,7 +13,8 @@ namespace web_server_idf {
static const char *const TAG = "web_server_idf_utils";
void url_decode(char *str) {
size_t url_decode(char *str) {
char *start = str;
char *ptr = str, buf;
for (; *str; str++, ptr++) {
if (*str == '%') {
@@ -31,7 +32,8 @@ void url_decode(char *str) {
*ptr = *str;
}
}
*ptr = *str;
*ptr = '\0';
return ptr - start;
}
bool request_has_header(httpd_req_t *req, const char *name) { return httpd_req_get_hdr_value_len(req, name); }
@@ -8,6 +8,10 @@
namespace esphome {
namespace web_server_idf {
/// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space)
/// Returns the new length of the decoded string
size_t url_decode(char *str);
bool request_has_header(httpd_req_t *req, const char *name);
optional<std::string> request_get_header(httpd_req_t *req, const char *name);
optional<std::string> request_get_url_query(httpd_req_t *req);
@@ -247,11 +247,20 @@ optional<std::string> AsyncWebServerRequest::get_header(const char *name) const
}
std::string AsyncWebServerRequest::url() const {
auto *str = strchr(this->req_->uri, '?');
if (str == nullptr) {
return this->req_->uri;
auto *query_start = strchr(this->req_->uri, '?');
std::string result;
if (query_start == nullptr) {
result = this->req_->uri;
} else {
result = std::string(this->req_->uri, query_start - this->req_->uri);
}
return std::string(this->req_->uri, str - this->req_->uri);
// Decode URL-encoded characters in-place (e.g., %20 -> space)
// This matches AsyncWebServer behavior on Arduino
if (!result.empty()) {
size_t new_len = url_decode(&result[0]);
result.resize(new_len);
}
return result;
}
std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); }
-10
View File
@@ -12,12 +12,6 @@
#include <string>
#include <vector>
#ifdef USE_ESP32_FRAMEWORK_ARDUINO
#include <WiFi.h>
#include <WiFiType.h>
#include <esp_wifi.h>
#endif
#ifdef USE_LIBRETINY
#include <WiFi.h>
#endif
@@ -578,10 +572,6 @@ class WiFiComponent : public Component {
static void s_wifi_scan_done_callback(void *arg, STATUS status);
#endif
#ifdef USE_ESP32_FRAMEWORK_ARDUINO
void wifi_event_callback_(arduino_event_id_t event, arduino_event_info_t info);
void wifi_scan_done_callback_();
#endif
#ifdef USE_ESP32
void wifi_process_event_(IDFWiFiEvent *data);
#endif
+39
View File
@@ -1981,6 +1981,26 @@ MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend(
)
def _validate_no_slash(value):
"""Validate that a name does not contain '/' characters.
The '/' character is used as a path separator in web server URLs,
so it cannot be used in entity or device names.
"""
if "/" in value:
raise Invalid(
f"Name cannot contain '/' character (used as URL path separator): {value}"
)
return value
# Maximum length for entity, device, and area names
# This ensures web server URL IDs fit in a 280-byte buffer:
# domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes
# Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields
NAME_MAX_LENGTH = 120
def _validate_entity_name(value):
value = string(value)
try:
@@ -1991,9 +2011,28 @@ def _validate_entity_name(value):
requires_friendly_name(
"Name cannot be None when esphome->friendly_name is not set!"
)(value)
if value is not None:
# Validate length for web server URL compatibility
if len(value) > NAME_MAX_LENGTH:
raise Invalid(
f"Name is too long ({len(value)} chars). "
f"Maximum length is {NAME_MAX_LENGTH} characters."
)
# Validate no '/' in name for web server URL compatibility
_validate_no_slash(value)
return value
def string_no_slash(value):
"""Validate a string that cannot contain '/' characters.
Used for device and area names where '/' is reserved as a URL path separator.
Use with cv.Length() to also enforce maximum length.
"""
value = string(value)
return _validate_no_slash(value)
ENTITY_BASE_SCHEMA = Schema(
{
Optional(CONF_NAME): _validate_entity_name,
+5 -3
View File
@@ -186,14 +186,14 @@ else:
AREA_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(Area),
cv.Required(CONF_NAME): cv.string,
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
}
)
DEVICE_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(Device),
cv.Required(CONF_NAME): cv.string,
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
}
)
@@ -207,7 +207,9 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_NAME): cv.valid_name,
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(cv.string, cv.Length(max=120)),
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(
cv.string_no_slash, cv.Length(max=120)
),
cv.Optional(CONF_AREA): validate_area_config,
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
cv.Required(CONF_BUILD_PATH): cv.string,
+2
View File
@@ -99,6 +99,8 @@ class EntityBase {
return this->device_->get_device_id();
}
void set_device(Device *device) { this->device_ = device; }
// Get the device this entity belongs to (nullptr if main device)
Device *get_device() const { return this->device_; }
#endif
// Check if this entity has state
+1 -9
View File
@@ -3,20 +3,12 @@
#include <cstdint>
#include "gpio.h"
#if defined(USE_ESP32_FRAMEWORK_ESP_IDF)
#if defined(USE_ESP32)
#include <esp_attr.h>
#ifndef PROGMEM
#define PROGMEM
#endif
#elif defined(USE_ESP32_FRAMEWORK_ARDUINO)
#include <esp_attr.h>
#ifndef PROGMEM
#define PROGMEM
#endif
#elif defined(USE_ESP8266)
#include <c_types.h>
+5 -5
View File
@@ -1,12 +1,12 @@
#pragma once
#if defined(USE_ESP32)
#include <atomic>
#include <cstddef>
#ifdef USE_ESP32
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#endif
/*
* Lock-free queue for single-producer single-consumer scenarios.
@@ -95,7 +95,7 @@ template<class T, uint8_t SIZE> class LockFreeQueue {
}
protected:
T *buffer_[SIZE];
T *buffer_[SIZE]{};
// Atomic: written by producer (push/increment), read+reset by consumer (get_and_reset)
std::atomic<uint16_t> dropped_count_; // 65535 max - more than enough for drop tracking
// Atomic: written by consumer (pop), read by producer (push) to check if full
@@ -106,6 +106,7 @@ template<class T, uint8_t SIZE> class LockFreeQueue {
std::atomic<uint8_t> tail_;
};
#ifdef USE_ESP32
// Extended queue with task notification support
template<class T, uint8_t SIZE> class NotifyingLockFreeQueue : public LockFreeQueue<T, SIZE> {
public:
@@ -140,7 +141,6 @@ template<class T, uint8_t SIZE> class NotifyingLockFreeQueue : public LockFreeQu
private:
TaskHandle_t task_to_notify_;
};
#endif
} // namespace esphome
#endif // defined(USE_ESP32)
+1 -1
View File
@@ -22,7 +22,7 @@ pillow==11.3.0
cairosvg==2.8.2
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.0
bleak==2.1.1
# esp-idf >= 5.0 requires this
pyparsing >= 3.0
@@ -11,3 +11,4 @@ display:
dc_pin: 21
reset_pin: 22
invert_colors: false
data_rate: 10MHz
@@ -0,0 +1,15 @@
esp32_ble_tracker:
sensor:
- platform: bthome_mithermometer
mac_address: A4:C1:38:4E:16:78
temperature:
name: "BTHome Temperature"
humidity:
name: "BTHome Humidity"
battery_level:
name: "BTHome Battery"
battery_voltage:
name: "BTHome Battery Voltage"
signal_strength:
name: "BTHome Signal"
@@ -0,0 +1,4 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
<<: !include common.yaml
@@ -9,6 +9,7 @@ display:
invert_colors: True
cs_pin: 20
dc_pin: 21
data_rate: 20MHz
pages:
- id: page1
lambda: |-
+1
View File
@@ -6,6 +6,7 @@ display:
dc_pin: 13
reset_pin: 21
invert_colors: false
data_rate: 20MHz
lambda: |-
// Draw an analog clock in the center of the screen
int centerX = it.get_width() / 2;
+2
View File
@@ -11,6 +11,7 @@ display:
cs_pin: ${cs_pin1}
dc_pin: ${dc_pin1}
reset_pin: ${reset_pin1}
data_rate: 20MHz
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
- platform: ili9xxx
@@ -27,5 +28,6 @@ display:
reset_pin: ${reset_pin2}
auto_clear_enabled: false
rotation: 90
data_rate: 20MHz
lambda: |-
it.fill(Color::WHITE);
@@ -9,6 +9,7 @@ display:
cs_pin: 20
dc_pin: 21
reset_pin: 22
data_rate: 20MHz
invert_colors: true
<<: !include common.yaml
@@ -8,6 +8,7 @@ display:
spi_id: spi_bus
id: main_lcd
model: ili9342
data_rate: 20MHz
cs_pin: 20
dc_pin: 17
reset_pin: 21
+1
View File
@@ -5,6 +5,7 @@ display:
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}
reset_pin: ${reset_pin}
data_rate: 500kHz
invert_colors: false
lambda: |-
// Draw a QR code in the center of the screen
+1
View File
@@ -6,6 +6,7 @@ display:
cs_pin: ${disp_cs_pin}
dc_pin: ${dc_pin}
reset_pin: ${reset_pin}
data_rate: 20MHz
invert_colors: false
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
@@ -502,3 +502,60 @@ def test_only_with_user_value_overrides_default() -> None:
result = schema({"mqtt_id": "custom_id"})
assert result.get("mqtt_id") == "custom_id"
@pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度"))
def test_string_no_slash__valid(value: str) -> None:
actual = config_validation.string_no_slash(value)
assert actual == value
@pytest.mark.parametrize("value", ("has/slash", "a/b/c", "/leading", "trailing/"))
def test_string_no_slash__slash_rejected(value: str) -> None:
with pytest.raises(Invalid, match="cannot contain '/' character"):
config_validation.string_no_slash(value)
def test_string_no_slash__long_string_allowed() -> None:
# string_no_slash doesn't enforce length - use cv.Length() separately
long_value = "x" * 200
assert config_validation.string_no_slash(long_value) == long_value
def test_string_no_slash__empty() -> None:
assert config_validation.string_no_slash("") == ""
@pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器"))
def test_validate_entity_name__valid(value: str) -> None:
actual = config_validation._validate_entity_name(value)
assert actual == value
def test_validate_entity_name__slash_rejected() -> None:
with pytest.raises(Invalid, match="cannot contain '/' character"):
config_validation._validate_entity_name("has/slash")
def test_validate_entity_name__max_length() -> None:
# 120 chars should pass
assert config_validation._validate_entity_name("x" * 120) == "x" * 120
# 121 chars should fail
with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"):
config_validation._validate_entity_name("x" * 121)
def test_validate_entity_name__none_without_friendly_name() -> None:
# When name is "None" and friendly_name is not set, it should fail
CORE.friendly_name = None
with pytest.raises(Invalid, match="friendly_name is not set"):
config_validation._validate_entity_name("None")
def test_validate_entity_name__none_with_friendly_name() -> None:
# When name is "None" but friendly_name is set, it should return None
CORE.friendly_name = "My Device"
result = config_validation._validate_entity_name("None")
assert result is None
CORE.friendly_name = None # Reset