mirror of
https://github.com/esphome/esphome.git
synced 2026-09-01 10:36:01 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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_());
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""");
|
||||
break;
|
||||
case '&':
|
||||
stream->print("&");
|
||||
break;
|
||||
case '<':
|
||||
stream->print("<");
|
||||
break;
|
||||
case '>':
|
||||
stream->print(">");
|
||||
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) {
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: |-
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user