Merge branch 'dev' into jesserockz-2026-342

This commit is contained in:
Jesse Hills
2026-07-01 16:34:18 +12:00
committed by GitHub
82 changed files with 4522 additions and 2611 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
ARG BUILD_BASE_VERSION=2025.04.0
ARG BUILD_BASE_VERSION=2026.06.1
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base
+5 -2
View File
@@ -15,7 +15,6 @@
// uncomment and edit the path in order to pass through local USB serial to the container
// , "--device=/dev/ttyACM0"
],
"appPort": 6052,
// if you are using avahi in the host device, uncomment these to allow the
// devcontainer to find devices via mdns
//"mounts": [
@@ -41,7 +40,11 @@
],
"settings": {
"python.languageServer": "Pylance",
"python.pythonPath": "/usr/bin/python3",
// Use the container's pre-provisioned venv (built by the Dockerfile, outside the
// bind-mounted workspace) rather than a ./venv that may leak in from the host and
// mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv).
"python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python",
"python.terminal.activateEnvironment": true,
"pylint.args": [
"--rcfile=${workspaceFolder}/pyproject.toml"
],
+1 -1
View File
@@ -820,7 +820,7 @@ jobs:
run: echo ${{ matrix.components }}
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
+6
View File
@@ -709,3 +709,9 @@ This document provides essential context for AI models interacting with this pro
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
```
## 9. English Language
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
audience, including non-native English speakers.
+3
View File
@@ -123,6 +123,7 @@ esphome/components/cs5460a/* @balrog-kun
esphome/components/cse7761/* @berfenger
esphome/components/cst226/* @clydebarrow
esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/dac7678/* @NickB1
@@ -386,6 +387,7 @@ esphome/components/pcm5122/* @remcom
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
esphome/components/pixoo/* @jesserockz
esphome/components/pm1006/* @habbie
esphome/components/pm2005/* @andrewjswan
esphome/components/pmsa003i/* @sjtrny
@@ -405,6 +407,7 @@ esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz
esphome/components/pylontech/* @functionpointer
esphome/components/qmi8658/* @clydebarrow
esphome/components/qmp6988/* @andrewpc
esphome/components/qr_code/* @wjtje
esphome/components/qspi_dbi/* @clydebarrow
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23
RUN \
platformio settings set enable_telemetry No \
+4
View File
@@ -21,6 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native ESP-IDF install on the persistent cache root, not the
# container's ephemeral user cache dir (re-downloaded on every restart).
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
# If /build is mounted, use that as the build path
# otherwise use path in /config (so that builds aren't lost on container restart)
if [[ -d /build ]]; then
@@ -15,6 +15,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native ESP-IDF install on the persistent /data volume, not the
# container's ephemeral user cache dir (wiped on every add-on update/restart).
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
if bashio::config.true 'leave_front_door_open'; then
export DISABLE_HA_AUTHENTICATION=true
fi
+1 -1
View File
@@ -2,6 +2,6 @@ esphome:
name: docker-test-ln882x-arduino
ln882x:
board: generic-ln882hki
board: generic-ln882h
logger:
+4 -1
View File
@@ -2386,7 +2386,10 @@ def parse_args(argv):
)
parser_clean_all = subparsers.add_parser(
"clean-all", help="Clean all build and platform files."
"clean-all",
help="Clean all build and platform files, including machine-global "
"toolchain caches shared by all configurations, so other projects will "
"re-download them on next build.",
)
parser_clean_all.add_argument(
"configuration", help="Your YAML file or configuration directory.", nargs="*"
File diff suppressed because it is too large Load Diff
@@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
void loop() override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
void register_connection(BluetoothConnection *connection) {
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this;
}
#endif
}
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
+6
View File
@@ -0,0 +1,6 @@
import esphome.codegen as cg
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["i2c"]
cst9220_ns = cg.esphome_ns.namespace("cst9220")
@@ -0,0 +1,36 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from .. import cst9220_ns
CST9220Touchscreen = cst9220_ns.class_(
"CST9220Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST9220Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x5A))
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
if reset_pin := config.get(CONF_RESET_PIN):
cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin)))
@@ -0,0 +1,141 @@
#include "cst9220_touchscreen.h"
#include "esphome/core/helpers.h"
#include <cinttypes>
namespace esphome::cst9220 {
void CST9220Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
delay(5);
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
// Wait for the controller to leave its bootloader before talking to it.
this->set_timeout(30, [this] { this->continue_setup_(); });
}
void CST9220Touchscreen::continue_setup_() {
uint8_t buffer[4];
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// Enter command mode so the configuration registers can be read.
if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to enter command mode"));
this->mark_failed();
return;
}
delay(10);
// The firmware check code confirms that valid firmware is loaded.
if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to read check code"));
this->mark_failed();
return;
}
uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]);
if ((checkcode & 0xFFFF0000) != 0xCACA0000) {
ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode);
this->status_set_error(LOG_STR("Invalid firmware check code"));
this->mark_failed();
return;
}
// Read the panel resolution unless the user supplied calibration values.
if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) {
if (this->x_raw_max_ == this->x_raw_min_)
this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]);
if (this->y_raw_max_ == this->y_raw_min_)
this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]);
}
// Read the chip type and project id and validate the controller.
if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to read chip ID"));
this->mark_failed();
return;
}
this->chip_id_ = encode_uint16(buffer[3], buffer[2]);
this->project_id_ = encode_uint16(buffer[1], buffer[0]);
if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) {
ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_);
this->status_set_error(LOG_STR("Unknown chip ID"));
this->mark_failed();
return;
}
// Fall back to the display dimensions if the resolution read failed.
if (this->x_raw_max_ == this->x_raw_min_)
this->x_raw_max_ = this->display_->get_native_width();
if (this->y_raw_max_ == this->y_raw_min_)
this->y_raw_max_ = this->display_->get_native_height();
this->setup_complete_ = true;
}
void CST9220Touchscreen::update_touches() {
if (!this->setup_complete_)
return;
uint8_t data[CST9220_DATA_LENGTH];
// Only an actual I2C failure should skip the update; a successful read with no
// touches is a real "all fingers lifted" state that must flow through so the
// base class can generate the release event.
if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) {
this->status_set_warning();
this->skip_update_ = true;
return;
}
this->status_clear_warning();
// Acknowledge the report so the controller can prepare the next one.
uint8_t ack = TOUCH_ACK;
this->write_register16(REG_TOUCH_DATA, &ack, 1);
// A valid report carries the ACK marker at offset 6; offset 0 holds the first
// point and must be neither the ACK marker nor empty. Anything else means no
// valid touch data this cycle, which we report as zero touches (not a skip).
if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK)
return;
uint8_t num_touches = data[5] & 0x7F;
if (num_touches > CST9220_MAX_TOUCHES)
num_touches = CST9220_MAX_TOUCHES;
for (uint8_t i = 0; i < num_touches; i++) {
// The first point starts at offset 0; subsequent points are offset by the
// two status bytes that follow it.
const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2);
uint8_t id = p[0] >> 4;
uint8_t event = p[0] & 0x0F;
if (event != TOUCH_EVENT_DOWN)
continue;
// p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs.
uint16_t x = (p[1] << 4) | (p[3] >> 4);
uint16_t y = (p[2] << 4) | (p[3] & 0x0F);
ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y);
this->add_raw_touch_position_(id, x, y);
}
}
void CST9220Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG,
"CST9220 Touchscreen:\n"
" Chip ID: 0x%04X\n"
" Project ID: 0x%04X\n"
" X Raw Min: %d, X Raw Max: %d\n"
" Y Raw Min: %d, Y Raw Max: %d",
this->chip_id_, this->project_id_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_,
this->y_raw_max_);
LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
}
} // namespace esphome::cst9220
@@ -0,0 +1,50 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::cst9220 {
static const char *const TAG = "cst9220.touchscreen";
// The CST92xx family uses 16-bit (big-endian) register addresses.
static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report
static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode
static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code
static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution
static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id
static const uint8_t TOUCH_ACK = 0xAB;
static const uint8_t TOUCH_EVENT_DOWN = 0x06;
static const uint16_t CST9220_CHIP_ID = 0x9220;
static const uint16_t CST9217_CHIP_ID = 0x9217;
// Maximum simultaneous touch points reported by the family.
static const uint8_t CST9220_MAX_TOUCHES = 5;
// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead.
static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5;
class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
void continue_setup_();
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
uint16_t chip_id_{};
uint16_t project_id_{};
bool setup_complete_{};
};
} // namespace esphome::cst9220
+11 -2
View File
@@ -94,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status)
}
void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) {
// Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a
// v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B),
// but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger
// frame would overflow packet_.receive.data.
if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) {
global_esp_now->receive_packet_queue_.increment_dropped_count();
return;
}
// Allocate an event from the pool
ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate();
if (packet == nullptr) {
@@ -327,13 +336,13 @@ void ESPNowComponent::loop() {
// Log dropped received packets periodically
uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count();
if (received_dropped > 0) {
ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped);
ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped);
}
// Log dropped send packets periodically
uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count();
if (send_dropped > 0) {
ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped);
ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped);
}
}
@@ -839,7 +839,7 @@ void EthernetComponent::dump_connect_params_() {
case ETH_SPEED_100M:
link_speed = 100;
break;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
case ETH_SPEED_1000M:
link_speed = 1000;
break;
@@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2
constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2
constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1
class GrowattSolar final : public PollingComponent, public modbus::ModbusDevice {
class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void loop() override;
void update() override;
+10 -2
View File
@@ -25,6 +25,7 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.types import ConfigType
CONF_ENERGY_PRODUCTION_DAY = "energy_production_day"
CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production"
@@ -47,7 +48,7 @@ CODEOWNERS = ["@leeuwte"]
growatt_solar_ns = cg.esphome_ns.namespace("growatt_solar")
GrowattSolar = growatt_solar_ns.class_(
"GrowattSolar", cg.PollingComponent, modbus.ModbusDevice
"GrowattSolar", cg.PollingComponent, modbus.ModbusClientDevice
)
PHASE_SENSORS = {
@@ -162,10 +163,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("growatt_solar", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION]))
@@ -8,7 +8,7 @@
namespace esphome::havells_solar {
class HavellsSolar final : public PollingComponent, public modbus::ModbusDevice {
class HavellsSolar final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) {
this->phases_[phase].setup = true;
+10 -2
View File
@@ -28,6 +28,7 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
from esphome.types import ConfigType
CONF_ENERGY_PRODUCTION_DAY = "energy_production_day"
CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production"
@@ -58,7 +59,7 @@ CODEOWNERS = ["@sourabhjaiswal"]
havells_solar_ns = cg.esphome_ns.namespace("havells_solar")
HavellsSolar = havells_solar_ns.class_(
"HavellsSolar", cg.PollingComponent, modbus.ModbusDevice
"HavellsSolar", cg.PollingComponent, modbus.ModbusClientDevice
)
PHASE_SENSORS = {
@@ -216,10 +217,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("havells_solar", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_FREQUENCY in config:
sens = await sensor.new_sensor(config[CONF_FREQUENCY])
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::kuntze {
class Kuntze final : public PollingComponent, public modbus::ModbusDevice {
class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; }
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; }
+10 -2
View File
@@ -15,13 +15,14 @@ from esphome.const import (
UNIT_EMPTY,
UNIT_PH,
)
from esphome.types import ConfigType
CODEOWNERS = ["@ssieb"]
AUTO_LOAD = ["modbus"]
kuntze_ns = cg.esphome_ns.namespace("kuntze")
Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusDevice)
Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusClientDevice)
CONF_DIS1 = "dis1"
CONF_DIS2 = "dis2"
@@ -88,10 +89,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("kuntze", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_PH in config:
conf = config[CONF_PH]
+5 -5
View File
@@ -211,14 +211,14 @@ def _notify_old_style(config):
# The dev and latest branches will be at *least* this version, which is what matters.
# Use GitHub releases directly to avoid PlatformIO moderation delays.
ARDUINO_VERSIONS = {
"dev": (cv.Version(1, 12, 1), "https://github.com/libretiny-eu/libretiny.git"),
"dev": (cv.Version(1, 13, 0), "https://github.com/libretiny-eu/libretiny.git"),
"latest": (
cv.Version(1, 12, 1),
"https://github.com/libretiny-eu/libretiny.git#v1.12.1",
cv.Version(1, 13, 0),
"https://github.com/libretiny-eu/libretiny.git#v1.13.0",
),
"recommended": (
cv.Version(1, 12, 1),
"https://github.com/libretiny-eu/libretiny.git#v1.12.1",
cv.Version(1, 13, 0),
"https://github.com/libretiny-eu/libretiny.git#v1.13.0",
),
}
@@ -359,7 +359,9 @@ if __name__ == "__main__":
check_base_code(BASE_CODE_INIT)
# list all boards from ltchiptool
components_dir = Path(__file__).parent.parent
boards = [Board(b) for b in Board.get_list()]
# Board.get_list() returns glob (filesystem) order, which is non-deterministic
# and produces noisy diffs on regeneration; sort by board id for stable output.
boards = sorted((Board(b) for b in Board.get_list()), key=lambda b: b.name)
# keep track of all supported root- and chip-families
components = set()
families = {}
+412 -85
View File
@@ -15,26 +15,38 @@ Any manual changes WILL BE LOST on regeneration.
from esphome.components.libretiny.const import FAMILY_LN882H
LN882X_BOARDS = {
"generic-ln882hki": {
"name": "Generic - LN882HKI",
"generic-ln882h": {
"name": "Generic - LN882H",
"family": FAMILY_LN882H,
},
"wb02a": {
"name": "WB02A Wi-Fi/BLE Module",
"family": FAMILY_LN882H,
},
"wl2s": {
"name": "WL2S Wi-Fi/BLE Module",
"generic-ln882h-tuya": {
"name": "Generic - LN882H (Tuya)",
"family": FAMILY_LN882H,
},
"ln-02": {
"name": "LN-02 Wi-Fi/BLE Module",
"family": FAMILY_LN882H,
},
"ln-cb3s-v1.0": {
"name": "LN-CB3S V1.0",
"family": FAMILY_LN882H,
},
"wb02a": {
"name": "WB02A Wi-Fi/BLE Module",
"family": FAMILY_LN882H,
},
"wl2h-u": {
"name": "WL2H-U Wi-Fi/BLE Module",
"family": FAMILY_LN882H,
},
"wl2s": {
"name": "WL2S Wi-Fi/BLE Module",
"family": FAMILY_LN882H,
},
}
LN882X_BOARD_PINS = {
"generic-ln882hki": {
"generic-ln882h": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
"WIRE0_SCL_2": 2,
@@ -153,27 +165,292 @@ LN882X_BOARD_PINS = {
"A6": 20,
"A7": 21,
},
"generic-ln882h-tuya": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
"WIRE0_SCL_2": 2,
"WIRE0_SCL_3": 3,
"WIRE0_SCL_4": 4,
"WIRE0_SCL_5": 5,
"WIRE0_SCL_6": 6,
"WIRE0_SCL_7": 7,
"WIRE0_SCL_8": 8,
"WIRE0_SCL_9": 9,
"WIRE0_SCL_10": 10,
"WIRE0_SCL_11": 11,
"WIRE0_SCL_12": 12,
"WIRE0_SCL_13": 19,
"WIRE0_SCL_14": 20,
"WIRE0_SCL_15": 21,
"WIRE0_SCL_16": 22,
"WIRE0_SCL_17": 23,
"WIRE0_SCL_18": 24,
"WIRE0_SCL_19": 25,
"WIRE0_SDA_0": 0,
"WIRE0_SDA_1": 1,
"WIRE0_SDA_2": 2,
"WIRE0_SDA_3": 3,
"WIRE0_SDA_4": 4,
"WIRE0_SDA_5": 5,
"WIRE0_SDA_6": 6,
"WIRE0_SDA_7": 7,
"WIRE0_SDA_8": 8,
"WIRE0_SDA_9": 9,
"WIRE0_SDA_10": 10,
"WIRE0_SDA_11": 11,
"WIRE0_SDA_12": 12,
"WIRE0_SDA_13": 19,
"WIRE0_SDA_14": 20,
"WIRE0_SDA_15": 21,
"WIRE0_SDA_16": 22,
"WIRE0_SDA_17": 23,
"WIRE0_SDA_18": 24,
"WIRE0_SDA_19": 25,
"SERIAL0_RX": 3,
"SERIAL0_TX": 2,
"SERIAL1_RX": 24,
"SERIAL1_TX": 25,
"ADC2": 0,
"ADC3": 1,
"ADC4": 4,
"ADC5": 19,
"ADC6": 20,
"ADC7": 21,
"PA00": 0,
"PA0": 0,
"PA01": 1,
"PA1": 1,
"PA02": 2,
"PA2": 2,
"PA03": 3,
"PA3": 3,
"PA04": 4,
"PA4": 4,
"PA05": 5,
"PA5": 5,
"PA06": 6,
"PA6": 6,
"PA07": 7,
"PA7": 7,
"PA08": 8,
"PA8": 8,
"PA09": 9,
"PA9": 9,
"PA10": 10,
"PA11": 11,
"PA12": 12,
"PB03": 19,
"PB3": 19,
"PB04": 20,
"PB4": 20,
"PB05": 21,
"PB5": 21,
"PB06": 22,
"PB6": 22,
"PB07": 23,
"PB7": 23,
"PB08": 24,
"PB8": 24,
"PB09": 25,
"PB9": 25,
"RX0": 3,
"RX1": 24,
"TX0": 2,
"TX1": 25,
"D0": 0,
"D1": 1,
"D2": 2,
"D3": 3,
"D4": 4,
"D5": 5,
"D6": 6,
"D7": 7,
"D8": 8,
"D9": 9,
"D10": 10,
"D11": 11,
"D12": 12,
"D13": 19,
"D14": 20,
"D15": 21,
"D16": 22,
"D17": 23,
"D18": 24,
"D19": 25,
"A2": 0,
"A3": 1,
"A4": 4,
"A5": 19,
"A6": 20,
"A7": 21,
},
"ln-02": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
"WIRE0_SCL_2": 2,
"WIRE0_SCL_3": 3,
"WIRE0_SCL_4": 9,
"WIRE0_SCL_5": 11,
"WIRE0_SCL_6": 19,
"WIRE0_SCL_7": 24,
"WIRE0_SCL_8": 25,
"WIRE0_SDA_0": 0,
"WIRE0_SDA_1": 1,
"WIRE0_SDA_2": 2,
"WIRE0_SDA_3": 3,
"WIRE0_SDA_4": 9,
"WIRE0_SDA_5": 11,
"WIRE0_SDA_6": 19,
"WIRE0_SDA_7": 24,
"WIRE0_SDA_8": 25,
"SERIAL0_RX": 3,
"SERIAL0_TX": 2,
"SERIAL1_RX": 24,
"SERIAL1_TX": 25,
"ADC2": 0,
"ADC3": 1,
"ADC5": 19,
"PA00": 0,
"PA0": 0,
"PA01": 1,
"PA1": 1,
"PA02": 2,
"PA2": 2,
"PA03": 3,
"PA3": 3,
"PA09": 9,
"PA9": 9,
"PA11": 11,
"PB03": 19,
"PB3": 19,
"PB08": 24,
"PB8": 24,
"PB09": 25,
"PB9": 25,
"RX0": 3,
"RX1": 24,
"SCL0": 9,
"SDA0": 9,
"TX0": 2,
"TX1": 25,
"D0": 11,
"D1": 19,
"D2": 3,
"D3": 24,
"D4": 2,
"D5": 25,
"D6": 1,
"D7": 0,
"D8": 9,
"A0": 19,
"A1": 1,
"A2": 0,
},
"ln-cb3s-v1.0": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
"WIRE0_SCL_2": 2,
"WIRE0_SCL_3": 3,
"WIRE0_SCL_4": 4,
"WIRE0_SCL_5": 5,
"WIRE0_SCL_6": 6,
"WIRE0_SCL_7": 9,
"WIRE0_SCL_8": 11,
"WIRE0_SCL_9": 20,
"WIRE0_SCL_10": 21,
"WIRE0_SCL_11": 22,
"WIRE0_SCL_12": 25,
"WIRE0_SDA_0": 0,
"WIRE0_SDA_1": 1,
"WIRE0_SDA_2": 2,
"WIRE0_SDA_3": 3,
"WIRE0_SDA_4": 4,
"WIRE0_SDA_5": 5,
"WIRE0_SDA_6": 6,
"WIRE0_SDA_7": 9,
"WIRE0_SDA_8": 11,
"WIRE0_SDA_9": 20,
"WIRE0_SDA_10": 21,
"WIRE0_SDA_11": 22,
"WIRE0_SDA_12": 25,
"SERIAL0_RX": 3,
"SERIAL0_TX": 2,
"SERIAL1_TX": 25,
"ADC2": 0,
"ADC3": 1,
"ADC4": 4,
"ADC6": 20,
"ADC7": 21,
"PA00": 0,
"PA0": 0,
"PA01": 1,
"PA1": 1,
"PA02": 2,
"PA2": 2,
"PA03": 3,
"PA3": 3,
"PA04": 4,
"PA4": 4,
"PA05": 5,
"PA5": 5,
"PA06": 6,
"PA6": 6,
"PA09": 9,
"PA9": 9,
"PA11": 11,
"PB04": 20,
"PB4": 20,
"PB05": 21,
"PB5": 21,
"PB06": 22,
"PB6": 22,
"PB09": 25,
"PB9": 25,
"RX0": 3,
"TX0": 2,
"TX1": 25,
"D0": 0,
"D1": 1,
"D2": 4,
"D3": 5,
"D4": 6,
"D5": 20,
"D6": 25,
"D7": 9,
"D8": 21,
"D9": 22,
"D10": 3,
"D11": 2,
"D12": 11,
"A0": 0,
"A1": 1,
"A2": 4,
"A3": 20,
"A4": 21,
},
"wb02a": {
"WIRE0_SCL_0": 1,
"WIRE0_SCL_1": 2,
"WIRE0_SCL_2": 3,
"WIRE0_SCL_3": 4,
"WIRE0_SCL_4": 5,
"WIRE0_SCL_5": 7,
"WIRE0_SCL_6": 9,
"WIRE0_SCL_7": 10,
"WIRE0_SCL_8": 24,
"WIRE0_SCL_9": 25,
"WIRE0_SCL_5": 6,
"WIRE0_SCL_6": 7,
"WIRE0_SCL_7": 9,
"WIRE0_SCL_8": 10,
"WIRE0_SCL_9": 24,
"WIRE0_SCL_10": 25,
"WIRE0_SDA_0": 1,
"WIRE0_SDA_1": 2,
"WIRE0_SDA_2": 3,
"WIRE0_SDA_3": 4,
"WIRE0_SDA_4": 5,
"WIRE0_SDA_5": 7,
"WIRE0_SDA_6": 9,
"WIRE0_SDA_7": 10,
"WIRE0_SDA_8": 24,
"WIRE0_SDA_9": 25,
"WIRE0_SDA_5": 6,
"WIRE0_SDA_6": 7,
"WIRE0_SDA_7": 9,
"WIRE0_SDA_8": 10,
"WIRE0_SDA_9": 24,
"WIRE0_SDA_10": 25,
"SERIAL0_RX": 3,
"SERIAL0_TX": 2,
"SERIAL1_RX": 24,
@@ -190,6 +467,8 @@ LN882X_BOARD_PINS = {
"PA4": 4,
"PA05": 5,
"PA5": 5,
"PA06": 6,
"PA6": 6,
"PA07": 7,
"PA7": 7,
"PA09": 9,
@@ -206,18 +485,128 @@ LN882X_BOARD_PINS = {
"TX0": 2,
"TX1": 25,
"D0": 7,
"D1": 5,
"D1": 6,
"D2": 3,
"D3": 10,
"D4": 2,
"D5": 1,
"D6": 4,
"D7": 9,
"D8": 24,
"D9": 25,
"D7": 5,
"D8": 9,
"D9": 24,
"D10": 25,
"A0": 1,
"A1": 4,
},
"wl2h-u": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
"WIRE0_SCL_2": 2,
"WIRE0_SCL_3": 3,
"WIRE0_SCL_4": 4,
"WIRE0_SCL_5": 5,
"WIRE0_SCL_6": 6,
"WIRE0_SCL_7": 7,
"WIRE0_SCL_8": 10,
"WIRE0_SCL_9": 11,
"WIRE0_SCL_10": 12,
"WIRE0_SCL_11": 19,
"WIRE0_SCL_12": 20,
"WIRE0_SCL_13": 21,
"WIRE0_SCL_14": 22,
"WIRE0_SCL_15": 23,
"WIRE0_SCL_16": 24,
"WIRE0_SCL_17": 25,
"WIRE0_SDA_0": 0,
"WIRE0_SDA_1": 1,
"WIRE0_SDA_2": 2,
"WIRE0_SDA_3": 3,
"WIRE0_SDA_4": 4,
"WIRE0_SDA_5": 5,
"WIRE0_SDA_6": 6,
"WIRE0_SDA_7": 7,
"WIRE0_SDA_8": 10,
"WIRE0_SDA_9": 11,
"WIRE0_SDA_10": 12,
"WIRE0_SDA_11": 19,
"WIRE0_SDA_12": 20,
"WIRE0_SDA_13": 21,
"WIRE0_SDA_14": 22,
"WIRE0_SDA_15": 23,
"WIRE0_SDA_16": 24,
"WIRE0_SDA_17": 25,
"SERIAL0_RX": 3,
"SERIAL0_TX": 2,
"SERIAL1_RX": 24,
"SERIAL1_TX": 25,
"ADC2": 0,
"ADC3": 1,
"ADC4": 4,
"ADC5": 19,
"ADC6": 20,
"ADC7": 21,
"PA00": 0,
"PA0": 0,
"PA01": 1,
"PA1": 1,
"PA02": 2,
"PA2": 2,
"PA03": 3,
"PA3": 3,
"PA04": 4,
"PA4": 4,
"PA05": 5,
"PA5": 5,
"PA06": 6,
"PA6": 6,
"PA07": 7,
"PA7": 7,
"PA10": 10,
"PA11": 11,
"PA12": 12,
"PB03": 19,
"PB3": 19,
"PB04": 20,
"PB4": 20,
"PB05": 21,
"PB5": 21,
"PB06": 22,
"PB6": 22,
"PB07": 23,
"PB7": 23,
"PB08": 24,
"PB8": 24,
"PB09": 25,
"PB9": 25,
"RX0": 3,
"RX1": 24,
"TX0": 2,
"TX1": 25,
"D0": 5,
"D1": 6,
"D2": 4,
"D3": 1,
"D4": 0,
"D5": 24,
"D6": 25,
"D7": 7,
"D8": 10,
"D9": 11,
"D10": 12,
"D11": 19,
"D12": 2,
"D13": 3,
"D14": 20,
"D15": 21,
"D16": 22,
"D17": 23,
"A0": 4,
"A1": 1,
"A2": 0,
"A3": 19,
"A4": 20,
"A5": 21,
},
"wl2s": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
@@ -298,68 +687,6 @@ LN882X_BOARD_PINS = {
"A1": 19,
"A2": 1,
},
"ln-02": {
"WIRE0_SCL_0": 0,
"WIRE0_SCL_1": 1,
"WIRE0_SCL_2": 2,
"WIRE0_SCL_3": 3,
"WIRE0_SCL_4": 9,
"WIRE0_SCL_5": 11,
"WIRE0_SCL_6": 19,
"WIRE0_SCL_7": 24,
"WIRE0_SCL_8": 25,
"WIRE0_SDA_0": 0,
"WIRE0_SDA_1": 1,
"WIRE0_SDA_2": 2,
"WIRE0_SDA_3": 3,
"WIRE0_SDA_4": 9,
"WIRE0_SDA_5": 11,
"WIRE0_SDA_6": 19,
"WIRE0_SDA_7": 24,
"WIRE0_SDA_8": 25,
"SERIAL0_RX": 3,
"SERIAL0_TX": 2,
"SERIAL1_RX": 24,
"SERIAL1_TX": 25,
"ADC2": 0,
"ADC3": 1,
"ADC5": 19,
"PA00": 0,
"PA0": 0,
"PA01": 1,
"PA1": 1,
"PA02": 2,
"PA2": 2,
"PA03": 3,
"PA3": 3,
"PA09": 9,
"PA9": 9,
"PA11": 11,
"PB03": 19,
"PB3": 19,
"PB08": 24,
"PB8": 24,
"PB09": 25,
"PB9": 25,
"RX0": 3,
"RX1": 24,
"SCL0": 9,
"SDA0": 9,
"TX0": 2,
"TX1": 25,
"D0": 11,
"D1": 19,
"D2": 3,
"D3": 24,
"D4": 2,
"D5": 25,
"D6": 1,
"D7": 0,
"D8": 9,
"A0": 19,
"A1": 1,
"A2": 0,
},
}
BOARDS = LN882X_BOARDS
+2
View File
@@ -425,6 +425,8 @@ async def to_code(config):
dc_pin = await cg.gpio_pin_expression(dc_pin)
cg.add(var.set_dc_pin(dc_pin))
if config.get(CONF_INVERT_COLORS):
cg.add(var.set_invert_colors(True))
if lamb := config.get(CONF_LAMBDA):
lambda_ = await cg.process_lambda(
lamb, [(display.DisplayRef, "it")], return_type=cg.void
+3
View File
@@ -151,6 +151,9 @@ class MipiSpi : public display::Display,
this->reset_pin_->digital_write(false);
delay(5);
this->reset_pin_->digital_write(true);
} else {
// no reset pin, send software reset command
this->write_command_(SW_RESET_CMD);
}
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
+1 -6
View File
@@ -24,13 +24,11 @@ from esphome.components.mipi import (
PWSET,
PWSETN,
SETEXTC,
SWRESET,
VMCTR,
VMCTR1,
VMCTR2,
VSCRSADD,
DriverChip,
delay,
)
from esphome.components.spi import TYPE_OCTAL
@@ -367,7 +365,6 @@ ST7796 = DriverChip(
width=320,
height=480,
initsequence=(
(SWRESET,),
(CSCON, 0xC3),
(CSCON, 0x96),
(VMCTR1, 0x1C),
@@ -728,8 +725,6 @@ DriverChip(
width=128,
height=160,
initsequence=(
SWRESET,
delay(10),
(FRMCTR1, 0x01, 0x2C, 0x2D),
(FRMCTR2, 0x01, 0x2C, 0x2D),
(FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D),
@@ -786,7 +781,7 @@ ST7796.extend(
bus_mode=TYPE_OCTAL,
mirror_x=True,
reset_pin=4,
dc_pin=0,
dc_pin={"number": 0, "ignore_strapping_warning": True},
invert_colors=True,
)
+8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from typing import Literal
from esphome import pins
@@ -10,6 +11,8 @@ from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN,
from esphome.cpp_helpers import gpio_pin_expression
import esphome.final_validate as fv
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["uart"]
modbus_ns = cg.esphome_ns.namespace("modbus")
@@ -129,4 +132,9 @@ async def register_modbus_server_device(var, config):
async def register_modbus_device(var, config):
# Remove before 2026.12.0
_LOGGER.warning(
"'register_modbus_device' is deprecated, use 'register_modbus_client_device' "
"instead. Will be removed in 2026.12.0"
)
return await register_modbus_client_device(var, config)
+3 -1
View File
@@ -197,7 +197,9 @@ class ModbusClientDevice {
};
// This is for compatibility with external components using the former class name
using ModbusDevice = ModbusClientDevice;
// Remove before 2026.12.0
using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0",
"2026.6.0") = ModbusClientDevice;
// Result of a server register handler: std::nullopt means success, otherwise the Modbus exception code to return.
using ServerResponseStatus = std::optional<ModbusExceptionCode>;
@@ -11,6 +11,7 @@ from esphome.components.modbus.helpers import (
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET
from esphome.cpp_helpers import logging
from esphome.types import ConfigType
from .const import (
CONF_ALLOW_DUPLICATE_COMMANDS,
@@ -42,7 +43,7 @@ MULTI_CONF = True
modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller")
ModbusController = modbus_controller_ns.class_(
"ModbusController", cg.PollingComponent, modbus.ModbusDevice
"ModbusController", cg.PollingComponent, modbus.ModbusClientDevice
)
SensorItem = modbus_controller_ns.struct("SensorItem")
@@ -117,7 +118,7 @@ def validate_modbus_register(config):
return config
def _final_validate(config):
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("modbus_controller", role="client")(
config
)
@@ -211,7 +212,7 @@ async def to_code(config):
async def register_modbus_device(var, config):
cg.add(var.set_address(config[CONF_ADDRESS]))
await cg.register_component(var, config)
return await modbus.register_modbus_device(var, config)
return await modbus.register_modbus_client_device(var, config)
def function_code_to_register(function_code):
+10 -1
View File
@@ -57,6 +57,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RTL87XX,
PlatformFramework,
)
@@ -318,7 +319,15 @@ CONFIG_SCHEMA = cv.All(
}
),
validate_config,
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]),
cv.only_on(
[
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_LN882X,
PLATFORM_RTL87XX,
]
),
_consume_mqtt_sockets,
)
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@jesserockz"]
+43
View File
@@ -0,0 +1,43 @@
import esphome.codegen as cg
from esphome.components import display, spi
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL
from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
AUTO_LOAD = ["split_buffer"]
CONF_PIXOO_ID = "pixoo_id"
pixoo_ns = cg.esphome_ns.namespace("pixoo")
Pixoo = pixoo_ns.class_("Pixoo", cg.PollingComponent, display.Display, spi.SPIDevice)
PixooModel = pixoo_ns.enum("PixooModel")
# Only the 64x64 panel is hardware-verified. Smaller Pixoo panels are assumed to share the
# same protocol; add them here once confirmed.
MODELS = {
"64X64": PixooModel.PIXOO_64,
}
CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(Pixoo),
cv.Optional(CONF_MODEL, default="64X64"): cv.enum(MODELS, upper=True),
}
).extend(spi.spi_device_schema(cs_pin_required=True, default_data_rate=8e6))
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
"pixoo", require_miso=False, require_mosi=True
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_MODEL])
await display.register_display(var, config)
await spi.register_spi_device(var, config, write_only=True)
if (lambda_config := config.get(CONF_LAMBDA)) is not None:
lambda_ = await cg.process_lambda(
lambda_config, [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))
@@ -0,0 +1,24 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID
from esphome.types import ConfigType
from ..display import CONF_PIXOO_ID, Pixoo, pixoo_ns
PixooLight = pixoo_ns.class_("PixooLight", light.LightOutput)
CONFIG_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(PixooLight),
cv.GenerateID(CONF_PIXOO_ID): cv.use_id(Pixoo),
# The LED board applies its own gamma, so default to no gamma correction here.
cv.Optional(CONF_GAMMA_CORRECT, default=0.0): cv.positive_float,
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_parented(var, config[CONF_PIXOO_ID])
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
#include "esphome/components/pixoo/pixoo.h"
#include "esphome/core/helpers.h"
namespace esphome::pixoo {
// Brightness-only light that drives the Pixoo panel's LIGHT command.
class PixooLight : public light::LightOutput, public Parented<Pixoo> {
public:
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS});
return traits;
}
void write_state(light::LightState *state) override {
float brightness;
state->current_values_as_brightness(&brightness);
this->parent_->set_panel_brightness(brightness);
}
};
} // namespace esphome::pixoo
+201
View File
@@ -0,0 +1,201 @@
#include "pixoo.h"
#include "esphome/core/log.h"
#include <cmath>
#include <cstring>
#include <utility>
namespace esphome::pixoo {
static const char *const TAG = "pixoo";
// Divoom LED-board packet protocol.
static constexpr uint8_t PACKET_HEAD = 0xAA;
static constexpr uint8_t PACKET_TAIL = 0xBB;
static constexpr uint8_t CMD_DATA = 0x00;
static constexpr uint8_t CMD_LIGHT = 0x01;
static constexpr uint8_t CMD_UNUSED = 0x21;
static constexpr uint8_t CMD_SET_RGB_IOUT = 0x22;
static constexpr size_t PACKET_HEADER_LEN = 4; // head + len(2) + cmd
static constexpr size_t PACKET_STATIC_LEN = 5; // header + tail
static constexpr uint8_t DEFAULT_IOUT = 75; // per-channel LED current / white balance default
// Pack a `0xAA len cmd data 0xBB` packet into buf; returns the packet length.
static inline size_t build_packet(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len) {
buf[0] = PACKET_HEAD;
buf[1] = static_cast<uint8_t>(len & 0xFF);
buf[2] = static_cast<uint8_t>((len >> 8) & 0xFF);
buf[3] = cmd;
if (data != nullptr && len > 0)
std::memcpy(buf + PACKET_HEADER_LEN, data, len);
buf[PACKET_HEADER_LEN + len] = PACKET_TAIL;
return len + PACKET_STATIC_LEN;
}
// Fill `total` bytes at buf with a single UNUSED padding packet.
static inline void pad_unused(uint8_t *buf, size_t total) {
const uint16_t len = static_cast<uint16_t>(total - PACKET_STATIC_LEN);
buf[0] = PACKET_HEAD;
buf[1] = static_cast<uint8_t>(len & 0xFF);
buf[2] = static_cast<uint8_t>((len >> 8) & 0xFF);
buf[3] = CMD_UNUSED;
buf[total - 1] = PACKET_TAIL;
}
float Pixoo::get_setup_priority() const { return setup_priority::PROCESSOR; }
void Pixoo::setup() {
const uint32_t num_pixels = static_cast<uint32_t>(this->model_) * this->model_;
this->data_size_ = num_pixels * 3;
// The frame is a DATA packet (header + RGB888 + tail) followed by a DMA-chunk-sized UNUSED
// packet, so the LED board completes its final DMA block.
this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK;
if (!this->buffer_.init(this->data_size_)) {
this->mark_failed(LOG_STR("Failed to allocate draw buffer"));
return;
}
// The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM.
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->frame_buffer_ = allocator.allocate(this->frame_size_);
if (this->frame_buffer_ == nullptr) {
this->buffer_.free();
this->mark_failed(LOG_STR("Failed to allocate frame buffer"));
return;
}
std::memset(this->frame_buffer_, 0, this->frame_size_);
// Pre-build the constant DATA-packet framing; only the RGB888 payload changes per frame.
this->frame_buffer_[0] = PACKET_HEAD;
this->frame_buffer_[1] = static_cast<uint8_t>(this->data_size_ & 0xFF);
this->frame_buffer_[2] = static_cast<uint8_t>((this->data_size_ >> 8) & 0xFF);
this->frame_buffer_[3] = CMD_DATA;
this->frame_buffer_[PACKET_HEADER_LEN + this->data_size_] = PACKET_TAIL;
pad_unused(this->frame_buffer_ + this->data_size_ + PACKET_STATIC_LEN, DMA_CHUNK);
this->spi_setup();
this->buffer_.fill(0x00);
// Set the per-channel LED current. Brightness is controlled separately via the light platform.
const uint8_t iout[3] = {DEFAULT_IOUT, DEFAULT_IOUT, DEFAULT_IOUT};
this->send_command_(CMD_SET_RGB_IOUT, iout, 3);
// Frames are pushed synchronously inside update(), so there is no loop() work to do and the
// component is idle between updates. Marking it done (LOOP_DONE) lets LVGL's
// update_when_display_idle option treat the panel as idle and drive frames on demand.
this->disable_loop();
}
void Pixoo::send_command_(uint8_t cmd, const uint8_t *data, uint16_t len) {
std::memset(this->cmd_buffer_, 0, DMA_CHUNK);
const size_t used = build_packet(this->cmd_buffer_, cmd, data, len);
if (DMA_CHUNK - used >= PACKET_STATIC_LEN)
pad_unused(this->cmd_buffer_ + used, DMA_CHUNK - used);
this->enable();
this->write_array(this->cmd_buffer_, DMA_CHUNK);
this->disable();
}
void Pixoo::set_panel_brightness(float brightness) {
const uint8_t pct = static_cast<uint8_t>(lroundf(clamp(brightness, 0.0f, 1.0f) * 100.0f));
this->send_command_(CMD_LIGHT, &pct, 1);
}
void Pixoo::update() {
this->do_update_();
for (size_t i = 0; i < this->data_size_; i++)
this->frame_buffer_[PACKET_HEADER_LEN + i] = this->buffer_[i];
this->enable();
this->write_array(this->frame_buffer_, this->frame_size_);
this->disable();
}
void Pixoo::set_pixel_(uint32_t index, Color color) {
const size_t off = static_cast<size_t>(index) * 3;
this->buffer_[off] = color.r;
this->buffer_[off + 1] = color.g;
this->buffer_[off + 2] = color.b;
}
void HOT Pixoo::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
return;
const int side = static_cast<int>(this->model_);
switch (this->rotation_) {
case display::DISPLAY_ROTATION_0_DEGREES:
break;
case display::DISPLAY_ROTATION_90_DEGREES:
std::swap(x, y);
x = side - x - 1;
break;
case display::DISPLAY_ROTATION_180_DEGREES:
x = side - x - 1;
y = side - y - 1;
break;
case display::DISPLAY_ROTATION_270_DEGREES:
std::swap(x, y);
y = side - y - 1;
break;
}
if (x < 0 || x >= side || y < 0 || y >= side)
return;
this->set_pixel_(static_cast<uint32_t>(y) * side + x, color);
}
void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
// Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping.
// Anything else defers to the base implementation, which decodes per pixel and routes through
// draw_pixel_at() so rotation, clipping and other color formats stay correct.
// NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the
// source of truth) -- keep them in sync if the base ever changes its source layout or decoding.
if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB ||
this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) {
display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset,
x_pad);
return;
}
const int side = static_cast<int>(this->model_);
const size_t line_stride = static_cast<size_t>(x_offset) + w + x_pad;
for (int y = 0; y != h; y++) {
const int dst_y = y_start + y;
if (dst_y < 0 || dst_y >= side)
continue;
size_t source_idx = (static_cast<size_t>(y_offset) + y) * line_stride + x_offset;
for (int x = 0; x != w; x++, source_idx++) {
const int dst_x = x_start + x;
if (dst_x < 0 || dst_x >= side)
continue;
const size_t byte_idx = source_idx * 2;
const uint16_t rgb565 =
big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8);
const uint8_t r5 = (rgb565 >> 11) & 0x1F;
const uint8_t g6 = (rgb565 >> 5) & 0x3F;
const uint8_t b5 = rgb565 & 0x1F;
this->set_pixel_(static_cast<uint32_t>(dst_y) * side + dst_x,
Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2)));
}
}
}
void Pixoo::fill(Color color) {
if (this->is_clipping()) {
display::Display::fill(color);
return;
}
for (size_t i = 0; i < this->data_size_; i += 3) {
this->buffer_[i] = color.r;
this->buffer_[i + 1] = color.g;
this->buffer_[i + 2] = color.b;
}
}
void Pixoo::dump_config() {
LOG_DISPLAY("", "Divoom Pixoo", this);
ESP_LOGCONFIG(TAG, " Model: %ux%u", (unsigned) this->model_, (unsigned) this->model_);
LOG_UPDATE_INTERVAL(this);
}
} // namespace esphome::pixoo
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "esphome/components/display/display.h"
#include "esphome/components/spi/spi.h"
#include "esphome/components/split_buffer/split_buffer.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::pixoo {
// The Pixoo's main board (where ESPHome runs) talks to a separate LED-driver board (a GD32/AT32
// MCU) over SPI using Divoom's packet protocol:
// 0xAA, len_lo, len_hi, cmd, <data...>, 0xBB
// The image is sent as a DATA (0x00) packet carrying width*height*3 bytes of RGB888; brightness is
// a separate LIGHT (0x01) command; the LED current is set once via SET_RGB_IOUT (0x22). Command
// packets are padded out to the LED board's 240-byte DMA chunk with an UNUSED (0x21) packet.
// The model selects the (square) panel side length.
enum PixooModel : uint8_t {
PIXOO_64 = 64,
};
class Pixoo : public display::Display,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_8MHZ> {
public:
explicit Pixoo(PixooModel model) : model_(model) {}
void setup() override;
void update() override;
void dump_config() override;
float get_setup_priority() const override;
// Brightness is controlled exclusively via the light platform: send a LIGHT command to the LED
// board (brightness 0..1 -> 0..100%).
void set_panel_brightness(float brightness);
display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; }
void fill(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
protected:
int get_width_internal() override { return static_cast<int>(this->model_); }
int get_height_internal() override { return static_cast<int>(this->model_); }
void set_pixel_(uint32_t index, Color color);
void send_command_(uint8_t cmd, const uint8_t *data, uint16_t len);
// Size of the LED board's SPI DMA chunk; the command scratch buffer is one chunk.
static constexpr size_t DMA_CHUNK = 240;
PixooModel model_;
size_t data_size_{0}; // RGB888 image bytes: model^2 * 3
size_t frame_size_{0}; // full SPI frame: DATA packet + trailing UNUSED packet
split_buffer::SplitBuffer buffer_{};
uint8_t *frame_buffer_{nullptr};
uint8_t cmd_buffer_[DMA_CHUNK]{};
};
} // namespace esphome::pixoo
+1 -1
View File
@@ -11,7 +11,7 @@ namespace esphome::pzemac {
template<typename... Ts> class ResetEnergyAction;
class PZEMAC final : public PollingComponent, public modbus::ModbusDevice {
class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; }
+10 -2
View File
@@ -26,11 +26,12 @@ from esphome.const import (
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
pzemac_ns = cg.esphome_ns.namespace("pzemac")
PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusDevice)
PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice)
# Actions
ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action)
@@ -97,10 +98,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("pzemac", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_VOLTAGE in config:
conf = config[CONF_VOLTAGE]
+1 -1
View File
@@ -9,7 +9,7 @@
namespace esphome::pzemdc {
class PZEMDC final : public PollingComponent, public modbus::ModbusDevice {
class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; }
+10 -2
View File
@@ -20,11 +20,12 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
pzemdc_ns = cg.esphome_ns.namespace("pzemdc")
PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusDevice)
PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice)
# Actions
ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action)
@@ -79,10 +80,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("pzemdc", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_VOLTAGE in config:
conf = config[CONF_VOLTAGE]
+13
View File
@@ -0,0 +1,13 @@
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.motion import MotionComponent
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["i2c", "motion"]
CONF_QMI8658_ID = "qmi8658_id"
# C++ namespace / class
qmi8658_ns = cg.esphome_ns.namespace("qmi8658")
QMI8658Component = qmi8658_ns.class_("QMI8658Component", MotionComponent, i2c.I2CDevice)
CONFIG_SCHEMA = {}
+93
View File
@@ -0,0 +1,93 @@
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import (
CONF_ACCELEROMETER_ODR,
CONF_ACCELEROMETER_RANGE,
CONF_GYROSCOPE_ODR,
CONF_GYROSCOPE_RANGE,
)
from esphome.components.motion import motion_schema, new_motion_component
import esphome.config_validation as cv
from . import QMI8658Component, qmi8658_ns
# Enum proxies (must match the C++ enum values exactly)
QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange")
ACCEL_RANGE_OPTIONS = {
"2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G,
"4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G,
"8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G,
"16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G,
}
QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange")
GYRO_RANGE_OPTIONS = {
"16DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_16,
"32DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_32,
"64DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_64,
"128DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_128,
"256DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_256,
"512DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_512,
"1024DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_1024,
"2048DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_2048,
}
QMI8658AccelODR = qmi8658_ns.enum("QMI8658AccelODR")
ACCEL_ODR_OPTIONS = {
"31_25HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_31_25,
"62_5HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_62_5,
"125HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_125,
"250HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_250,
"500HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_500,
"1000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_1000,
"2000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_2000,
"4000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_4000,
"8000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_8000,
}
QMI8658GyroODR = qmi8658_ns.enum("QMI8658GyroODR")
GYRO_ODR_OPTIONS = {
"31_25HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_31_25,
"62_5HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_62_5,
"125HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_125,
"250HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_250,
"500HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_500,
"1000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_1000,
"2000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_2000,
"4000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_4000,
"8000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_8000,
}
# Top-level CONFIG_SCHEMA
CONFIG_SCHEMA = (
motion_schema(QMI8658Component, has_accel=True, has_gyro=True)
.extend(
{
cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum(
ACCEL_RANGE_OPTIONS, upper=True
),
cv.Optional(CONF_ACCELEROMETER_ODR, default="1000HZ"): cv.enum(
ACCEL_ODR_OPTIONS, upper=True
),
cv.Optional(CONF_GYROSCOPE_RANGE, default="2048DPS"): cv.enum(
GYRO_RANGE_OPTIONS, upper=True
),
cv.Optional(CONF_GYROSCOPE_ODR, default="1000HZ"): cv.enum(
GYRO_ODR_OPTIONS, upper=True
),
}
)
.extend(i2c.i2c_device_schema(0x6B))
)
# Code generation
async def to_code(config):
var = await new_motion_component(config)
await i2c.register_i2c_device(var, config)
# Hardware configuration
cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE]))
cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR]))
cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE]))
cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR]))
+136
View File
@@ -0,0 +1,136 @@
#include "qmi8658.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
namespace esphome::qmi8658 {
static const char *const TAG = "qmi8658";
// Acceleration scale (g per LSB), indexed by accel_range_ >> 4.
// Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts).
static constexpr float ACCEL_SCALE[] = {
2.0f / 32768.0f,
4.0f / 32768.0f,
8.0f / 32768.0f,
16.0f / 32768.0f,
};
// Angular rate scale (°/s per LSB), indexed by gyro_range_ >> 4.
static constexpr float GYRO_SCALE[] = {
16.0f / 32768.0f, 32.0f / 32768.0f, 64.0f / 32768.0f, 128.0f / 32768.0f,
256.0f / 32768.0f, 512.0f / 32768.0f, 1024.0f / 32768.0f, 2048.0f / 32768.0f,
};
void QMI8658Component::setup() {
MotionComponent::setup();
// 1. Verify chip ID
uint8_t who_am_i = 0;
if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) {
ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address");
this->mark_failed();
return;
}
if (who_am_i != QMI8658_WHO_AM_I_VALUE) {
ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE);
this->mark_failed();
return;
}
// 2. Soft reset
if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) {
this->mark_failed();
return;
}
delay(15); // spec: wait for reset to complete
// 3. Serial interface: enable register address auto-increment
if (!this->write_byte(QMI8658_REG_CTRL1, QMI8658_CTRL1_VALUE)) {
this->mark_failed(LOG_STR("Failed to write REG_CTRL1"));
return;
}
// 4. Configure accelerometer (CTRL2 = range | ODR)
if (!this->write_byte(QMI8658_REG_CTRL2, (uint8_t) (this->accel_range_) | (uint8_t) (this->accel_odr_))) {
this->mark_failed(LOG_STR("Failed to write REG_CTRL2"));
return;
}
// 5. Configure gyroscope (CTRL3 = range | ODR)
if (!this->write_byte(QMI8658_REG_CTRL3, (uint8_t) (this->gyro_range_) | (uint8_t) (this->gyro_odr_))) {
this->mark_failed(LOG_STR("Failed to write REG_CTRL3"));
return;
}
// 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline)
if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) {
this->mark_failed(LOG_STR("Failed to write REG_CTRL5"));
this->mark_failed();
return;
}
// 7. Enable accelerometer and gyroscope
if (!this->write_byte(QMI8658_REG_CTRL7, QMI8658_CTRL7_ACC_EN | QMI8658_CTRL7_GYR_EN)) {
this->mark_failed(LOG_STR("Failed to write REG_CTRL7"));
return;
}
ESP_LOGCONFIG(TAG, "QMI8658 initialised successfully");
}
void QMI8658Component::dump_config() {
ESP_LOGCONFIG(TAG, "QMI8658 IMU:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, " Communication failed!");
return;
}
static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"};
static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s",
"±256°/s", "±512°/s", "±1024°/s", "±2048°/s"};
ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]);
ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]);
MotionComponent::dump_config();
}
bool QMI8658Component::update_data(motion::MotionData &data) {
if (this->is_failed())
return false;
// Read temperature + accel + gyro in one contiguous block starting at TEMP_L.
uint8_t raw_data[REG_READ_LEN];
if (!this->read_bytes(QMI8658_REG_TEMP_L, raw_data, REG_READ_LEN)) {
ESP_LOGW(TAG, "Failed to read IMU data");
return false;
}
// Data is little-endian (low byte first).
float scale = ACCEL_SCALE[this->accel_range_ >> 4];
int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]);
int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]);
int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]);
ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z);
data.acceleration[motion::X_AXIS] = raw_x * scale;
data.acceleration[motion::Y_AXIS] = raw_y * scale;
data.acceleration[motion::Z_AXIS] = raw_z * scale;
scale = GYRO_SCALE[this->gyro_range_ >> 4];
raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]);
raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]);
raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]);
ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z);
data.angular_rate[motion::X_AXIS] = raw_x * scale;
data.angular_rate[motion::Y_AXIS] = raw_y * scale;
data.angular_rate[motion::Z_AXIS] = raw_z * scale;
if (this->temperature_callback_.empty())
return true;
// Temperature: signed 16-bit, °C = raw / 256
int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]);
this->temperature_callback_.call(raw_t / 256.0f);
return true;
}
} // namespace esphome::qmi8658
+112
View File
@@ -0,0 +1,112 @@
#pragma once
#include "esphome/components/motion/motion_component.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::qmi8658 {
// Register map
static constexpr uint8_t QMI8658_REG_WHO_AM_I = 0x00;
static constexpr uint8_t QMI8658_REG_REVISION = 0x01;
static constexpr uint8_t QMI8658_REG_CTRL1 = 0x02; // serial interface / auto-increment
static constexpr uint8_t QMI8658_REG_CTRL2 = 0x03; // accelerometer ODR / range
static constexpr uint8_t QMI8658_REG_CTRL3 = 0x04; // gyroscope ODR / range
static constexpr uint8_t QMI8658_REG_CTRL5 = 0x06; // low-pass filter
static constexpr uint8_t QMI8658_REG_CTRL7 = 0x08; // sensor enable
static constexpr uint8_t QMI8658_REG_STATUS0 = 0x2E;
static constexpr uint8_t QMI8658_REG_TEMP_BASE = 0x33; // start of the data block
static constexpr uint8_t QMI8658_REG_TEMP_L = 0x33; // Low byte of temperature
static constexpr uint8_t QMI8658_REG_AX_L = 0x35;
static constexpr uint8_t QMI8658_REG_GX_L = 0x3B;
static constexpr uint8_t QMI8658_REG_RESET = 0x60;
// One contiguous read covers temperature (2) + accel (6) + gyro (6) starting at TEMP_L.
static constexpr uint8_t REG_READ_LEN = QMI8658_REG_GX_L + 6 - QMI8658_REG_TEMP_BASE; // 0x41 - 0x33 = 14
static constexpr uint8_t TEMP_OFFS = QMI8658_REG_TEMP_L - QMI8658_REG_TEMP_BASE; // 0
static constexpr uint8_t ACC_OFFS = QMI8658_REG_AX_L - QMI8658_REG_TEMP_BASE; // 2
static constexpr uint8_t GYR_OFFS = QMI8658_REG_GX_L - QMI8658_REG_TEMP_BASE; // 8
static constexpr uint8_t QMI8658_WHO_AM_I_VALUE = 0x05;
static constexpr uint8_t QMI8658_RESET_CMD = 0xB0;
// CTRL1: bit6 ADDR_AI (register address auto-increment); little-endian, 4-wire SPI
static constexpr uint8_t QMI8658_CTRL1_VALUE = 0x40;
// CTRL7: aEN (bit0) | gEN (bit1)
static constexpr uint8_t QMI8658_CTRL7_ACC_EN = 0x01;
static constexpr uint8_t QMI8658_CTRL7_GYR_EN = 0x02;
// Accelerometer range options (CTRL2 bits 6:4)
enum QMI8658AccelRange : uint8_t {
QMI8658_ACCEL_RANGE_2G = 0x00,
QMI8658_ACCEL_RANGE_4G = 0x10,
QMI8658_ACCEL_RANGE_8G = 0x20,
QMI8658_ACCEL_RANGE_16G = 0x30,
};
// Accelerometer ODR options (CTRL2 bits 3:0)
enum QMI8658AccelODR : uint8_t {
QMI8658_ACCEL_ODR_8000 = 0x00,
QMI8658_ACCEL_ODR_4000 = 0x01,
QMI8658_ACCEL_ODR_2000 = 0x02,
QMI8658_ACCEL_ODR_1000 = 0x03,
QMI8658_ACCEL_ODR_500 = 0x04,
QMI8658_ACCEL_ODR_250 = 0x05,
QMI8658_ACCEL_ODR_125 = 0x06,
QMI8658_ACCEL_ODR_62_5 = 0x07,
QMI8658_ACCEL_ODR_31_25 = 0x08,
};
// Gyroscope range options (CTRL3 bits 6:4)
enum QMI8658GyroRange : uint8_t {
QMI8658_GYRO_RANGE_16 = 0x00,
QMI8658_GYRO_RANGE_32 = 0x10,
QMI8658_GYRO_RANGE_64 = 0x20,
QMI8658_GYRO_RANGE_128 = 0x30,
QMI8658_GYRO_RANGE_256 = 0x40,
QMI8658_GYRO_RANGE_512 = 0x50,
QMI8658_GYRO_RANGE_1024 = 0x60,
QMI8658_GYRO_RANGE_2048 = 0x70,
};
// Gyroscope ODR options (CTRL3 bits 3:0)
enum QMI8658GyroODR : uint8_t {
QMI8658_GYRO_ODR_8000 = 0x00,
QMI8658_GYRO_ODR_4000 = 0x01,
QMI8658_GYRO_ODR_2000 = 0x02,
QMI8658_GYRO_ODR_1000 = 0x03,
QMI8658_GYRO_ODR_500 = 0x04,
QMI8658_GYRO_ODR_250 = 0x05,
QMI8658_GYRO_ODR_125 = 0x06,
QMI8658_GYRO_ODR_62_5 = 0x07,
QMI8658_GYRO_ODR_31_25 = 0x08,
};
// Main component class
class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice {
public:
// Lifecycle
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::DATA; }
// Configuration setters
void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; }
void set_accel_odr(QMI8658AccelODR o) { this->accel_odr_ = o; }
void set_gyro_range(QMI8658GyroRange r) { this->gyro_range_ = r; }
void set_gyro_odr(QMI8658GyroODR o) { this->gyro_odr_ = o; }
template<typename F> void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward<F>(cb)); }
protected:
bool update_data(motion::MotionData &data) override;
// Config
QMI8658AccelRange accel_range_{QMI8658_ACCEL_RANGE_4G};
QMI8658AccelODR accel_odr_{QMI8658_ACCEL_ODR_1000};
QMI8658GyroRange gyro_range_{QMI8658_GYRO_RANGE_2048};
QMI8658GyroODR gyro_odr_{QMI8658_GYRO_ODR_1000};
LazyCallbackManager<void(float)> temperature_callback_{};
};
} // namespace esphome::qmi8658
+39
View File
@@ -0,0 +1,39 @@
# YAML config keys
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
DEVICE_CLASS_TEMPERATURE,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
)
from esphome.cpp_generator import MockObj
from . import CONF_QMI8658_ID, QMI8658Component
CONFIG_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_TEMPERATURE,
).extend(
{
cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
cv.GenerateID(CONF_QMI8658_ID): cv.use_id(QMI8658Component),
}
)
async def to_code(config):
var = await sensor.new_sensor(config)
parent = await cg.get_variable(config[CONF_QMI8658_ID])
data = MockObj("data")
value_lambda = await cg.process_lambda(
var.publish_state(data),
[(cg.float_, str(data))],
)
cg.add(parent.add_temperature_listener(value_lambda))
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@
namespace esphome::sdm_meter {
class SDMMeter final : public PollingComponent, public modbus::ModbusDevice {
class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevice {
public:
void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) {
this->phases_[phase].setup = true;
+12 -2
View File
@@ -41,12 +41,15 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
CODEOWNERS = ["@polyfaces", "@jesserockz"]
sdm_meter_ns = cg.esphome_ns.namespace("sdm_meter")
SDMMeter = sdm_meter_ns.class_("SDMMeter", cg.PollingComponent, modbus.ModbusDevice)
SDMMeter = sdm_meter_ns.class_(
"SDMMeter", cg.PollingComponent, modbus.ModbusClientDevice
)
PHASE_SENSORS = {
CONF_VOLTAGE: sensor.sensor_schema(
@@ -145,10 +148,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("sdm_meter", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_TOTAL_POWER in config:
sens = await sensor.new_sensor(config[CONF_TOTAL_POWER])
+1 -1
View File
@@ -15,7 +15,7 @@ namespace esphome::selec_meter {
public: \
void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; }
class SelecMeter final : public PollingComponent, public modbus::ModbusDevice {
class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice {
public:
SELEC_METER_SENSOR(total_active_energy)
SELEC_METER_SENSOR(import_active_energy)
+10 -2
View File
@@ -32,6 +32,7 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
CODEOWNERS = ["@sourabhjaiswal"]
@@ -49,7 +50,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh"
selec_meter_ns = cg.esphome_ns.namespace("selec_meter")
SelecMeter = selec_meter_ns.class_(
"SelecMeter", cg.PollingComponent, modbus.ModbusDevice
"SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice
)
SENSORS = {
@@ -163,10 +164,17 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
return modbus.final_validate_modbus_device("selec_meter", role="client")(config)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await modbus.register_modbus_device(var, config)
await modbus.register_modbus_client_device(var, config)
for name in SENSORS:
if name in config:
sens = await sensor.new_sensor(config[name])
+3 -3
View File
@@ -147,9 +147,9 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None:
from esphome.core import CORE
CORE.name = TIDY_PROJECT_NAME
# config_path's parent is the data dir root: the IDF install lives at
# ``<parent>/.esphome/idf`` -- keep it beside (not inside) the per-run
# project dir so clearing the project doesn't force an IDF re-download.
# config_path's parent is the data dir root for per-run artifacts (idedata,
# converted pio_components). The IDF install is in the global cache dir,
# independent of this path.
CORE.config_path = work_dir.parent / "tidy.yaml"
CORE.build_path = work_dir
esp32 = CORE.data.setdefault(KEY_ESP32, {})
+22 -13
View File
@@ -9,6 +9,8 @@ import re
import shutil
import tempfile
import platformdirs
from esphome.config_validation import Version
from esphome.core import CORE
from esphome.framework_helpers import (
@@ -80,10 +82,18 @@ def _get_idf_tools_path() -> Path:
Returns:
Path object pointing to the ESP-IDF tools directory
"""
if "ESPHOME_ESP_IDF_PREFIX" in os.environ:
path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
# Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("")
# resolves to the CWD, which would install into (and let clean-all delete)
# the working directory by accident.
if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip():
path = Path(prefix).expanduser()
else:
path = CORE.data_dir / "idf"
# Machine-global so all projects share the multi-GB install instead of
# a per-config-directory copy. The user cache dir (not ~/.esphome)
# avoids colliding with data_dir when configs live in the home dir.
# appauthor=False drops the redundant <author>\ segment on Windows
# (which otherwise repeats "esphome\esphome\") to keep the path short.
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
# otherwise warns that the venv interpreter path doesn't match the install.
@@ -145,10 +155,11 @@ def _check_windows_path_length() -> None:
" fatal error: bits/c++config.h: No such file or directory\n"
" cannot execute 'as': CreateProcess: No such file or directory\n"
"To fix, either:\n"
" - Enable Windows long path support: set\n"
" HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n"
" to 1 and reboot, or\n"
" - Move your ESPHome project to a shorter path\n"
" - Enable Windows long path support, then reboot. In an elevated\n"
" PowerShell run:\n"
" Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' LongPathsEnabled 1\n"
" Details: https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation\n"
" - Or set ESPHOME_ESP_IDF_PREFIX to a shorter path (e.g. C:\\ESPHome\\idf)\n"
"Then delete the ESP-IDF tools directory above so the toolchain "
"reinstalls cleanly.",
tools_path,
@@ -553,7 +564,7 @@ def _check_esphome_idf_framework_install(
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean-all`` to force a re-download
# (``esphome clean`` only wipes the build dir, not the extracted framework
# under <data_dir>/idf/frameworks/<version>).
# under the global install dir's ``frameworks/<version>``).
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)
@@ -822,11 +833,9 @@ def _ccache_env() -> dict[str, str]:
Enabled by default whenever the ``ccache`` binary is on PATH; set
``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under
the IDF tools path. How widely it is shared depends on where that resolves:
across projects (and surviving ``clean-all``) when it is a common location
(``ESPHOME_ESP_IDF_PREFIX`` or the add-on ``/data``), but per-project under
``.esphome/idf`` for a default pip install, where ``clean-all`` clears it
along with the framework.
the IDF tools path (the machine-global cache dir, or
``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed
by ``esphome clean-all`` along with the framework.
Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles
instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build
+9
View File
@@ -653,6 +653,15 @@ def clean_all(configuration: list[str]):
elif item.is_dir() and item.name != "storage":
rmtree(item)
# The native ESP-IDF install lives in a machine-global cache dir, outside
# any .esphome data dir, so the per-config loop above won't reach it.
from esphome.espidf.framework import _get_idf_tools_path
idf_install_path = _get_idf_tools_path()
if idf_install_path.is_dir():
_LOGGER.info("Deleting %s", idf_install_path)
rmtree(idf_install_path)
# Clean PlatformIO project files
try:
from platformio.project.config import ProjectConfig
+2 -2
View File
@@ -224,7 +224,7 @@ build_unflags =
; This are common settings for the LibreTiny (all variants) using Arduino.
[common:libretiny-arduino]
extends = common:arduino
platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1
platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0
framework = arduino
lib_compat_mode = soft
lib_deps =
@@ -525,7 +525,7 @@ build_unflags =
[env:ln882h-arduino]
extends = common:libretiny-arduino
board = generic-ln882hki
board = generic-ln882h
build_flags =
${common:libretiny-arduino.build_flags}
${flags:runtime.build_flags}
+1
View File
@@ -23,6 +23,7 @@ bleak==2.1.1
smpclient==6.0.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.10.0 # native esp-idf toolchain global cache dir
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+78 -21
View File
@@ -238,6 +238,72 @@ class _ConflictWalk:
rejects: set[str]
@cache
def _get_test_config_components(component: str, platform: str) -> frozenset[str]:
"""Return the components referenced by a component's test config for a platform.
Loads ``tests/components/<component>/test.<platform>.yaml`` and extracts the
top-level component keys (and list ``platform:`` values). This lets the
conflict splitter see components that are only pulled in via a test config
(e.g. nRF52 ``network`` tests that also enable ``openthread``), which a
purely static AUTO_LOAD/CONFLICTS_WITH parse cannot discover -- notably for
components like ``api`` whose ``AUTO_LOAD`` is a callable.
Failures (missing file, parse error) are treated as empty so the splitter
never crashes on a malformed or absent test config.
"""
from esphome import yaml_util
test_file = (
Path(root_path) / "tests" / "components" / component / f"test.{platform}.yaml"
)
if not test_file.exists():
return frozenset()
try:
config = yaml_util.load_yaml(test_file)
except Exception: # noqa: BLE001 - never let a bad test config crash grouping
# Matches analyze_component_buses, which loads these same files and
# silently tolerates parse failures; surfacing it only here would be
# inconsistent and noisy.
return frozenset()
if not isinstance(config, dict):
return frozenset()
return frozenset(_extract_components_from_yaml(config))
@cache
def _conflict_walk(comp: str, platform: str) -> _ConflictWalk:
"""Build the platform-aware conflict walk for a single component.
Seeds the walk with the component itself plus any components pulled in via
its ``test.<platform>.yaml`` config, then folds in each seed's static
AUTO_LOAD closure and CONFLICTS_WITH declarations. Cached per
``(component, platform)`` since the test-config seeds are platform-specific.
"""
seeds = {comp} | set(_get_test_config_components(comp, platform))
walk = _ConflictWalk(loaded=set(seeds), rejects=set())
stack = list(seeds)
while stack:
metadata = parse_component_metadata(stack.pop())
walk.rejects |= metadata.conflicts_with
new = metadata.auto_load - walk.loaded
walk.loaded |= new
stack.extend(new)
return walk
def components_conflict(a: str, b: str, platform: str) -> bool:
"""Return True if components ``a`` and ``b`` cannot share a build on ``platform``.
Uses the same platform-aware conflict walk as :func:`split_conflicting_groups`
so callers (e.g. the no-bus redistribution in ``test_build_components.py``)
agree with how groups were originally split. The conflict relation is
symmetric even when only one side declares CONFLICTS_WITH.
"""
wa, wb = _conflict_walk(a, platform), _conflict_walk(b, platform)
return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint(wa.loaded)
def split_conflicting_groups(
grouped_components: dict[tuple[str, str], list[str]],
) -> dict[tuple[str, str], list[str]]:
@@ -250,33 +316,24 @@ def split_conflicting_groups(
conflict relation is treated as symmetric even when only one side
declares it (e.g. ethernet rejects wifi but wifi does not declare the
reverse).
The walk is platform-aware: in addition to the static AUTO_LOAD closure,
each ``(component, platform)`` walk is seeded with the components found in
that component's ``test.<platform>.yaml`` config. This catches conflicts
that only exist on a given platform and are expressed through the test
config rather than static metadata -- e.g. on nRF52 the ``network``/``api``
test configs also enable ``openthread``, which ``zigbee`` declares a
conflict with, so ``api`` and ``zigbee`` end up split there. On ESP32 those
test configs have no ``openthread``, so the components still group together.
"""
batch = {c for comps in grouped_components.values() for c in comps}
walks: dict[str, _ConflictWalk] = {}
for comp in batch:
walk = _ConflictWalk(loaded={comp}, rejects=set())
stack = [comp]
while stack:
metadata = parse_component_metadata(stack.pop())
walk.rejects |= metadata.conflicts_with
new = metadata.auto_load - walk.loaded
walk.loaded |= new
stack.extend(new)
walks[comp] = walk
def conflicts(a: str, b: str) -> bool:
wa, wb = walks[a], walks[b]
return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint(
wa.loaded
)
result: dict[tuple[str, str], list[str]] = {}
for (platform, signature), components in grouped_components.items():
buckets: list[list[str]] = []
for comp in components:
for bucket in buckets:
if not any(conflicts(comp, other) for other in bucket):
if not any(
components_conflict(comp, other, platform) for other in bucket
):
bucket.append(comp)
break
else:
+1 -1
View File
@@ -1,5 +1,5 @@
{
"target_module": "esphome.__main__",
"margin_pct": 15,
"margin_pct": 20,
"cumulative_us": 91000
}
+13 -2
View File
@@ -4,7 +4,12 @@
set -e
cd "$(dirname "$0")/.."
if [ ! -n "$VIRTUAL_ENV" ]; then
if [ -n "$VIRTUAL_ENV" ]; then
# A virtual environment is already active (e.g. the devcontainer's pre-provisioned
# esphome-venv). Install into it rather than creating a ./venv in the workspace.
created_venv=false
else
created_venv=true
if [ -x "$(command -v uv)" ]; then
uv venv --seed venv
else
@@ -26,4 +31,10 @@ mkdir -p .temp
echo
echo
echo "Virtual environment created. Run 'source venv/bin/activate' to use it."
if [ "$created_venv" = true ]; then
echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it."
else
echo "Dependencies installed into the active virtual environment:"
echo " $VIRTUAL_ENV"
echo "It is already active in this shell, so no 'source venv/bin/activate' is needed."
fi
+30 -8
View File
@@ -40,6 +40,7 @@ from script.analyze_component_buses import (
uses_local_file_references,
)
from script.helpers import (
components_conflict,
get_component_test_files,
is_validate_only_file,
parse_test_filename,
@@ -788,14 +789,35 @@ def run_grouped_component_tests(
if plat == platform and sig != NO_BUSES_SIGNATURE
]
if platform_groups:
# Distribute no_buses components round-robin across existing groups
for i, comp in enumerate(no_buses_comps):
sig, _ = platform_groups[i % len(platform_groups)]
grouped_components[(platform, sig)].append(comp)
else:
# No other groups for this platform - keep no_buses components together
grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps
# Distribute no_buses components round-robin across existing groups,
# but never place a component into a group it conflicts with. Conflict
# splitting (split_conflicting_groups) may have created sibling groups
# like "no_buses__conflict1" precisely to keep incompatible components
# apart (e.g. on nRF52, network pulls in openthread which zigbee
# conflicts with); redistribution must not silently undo that split.
leftover: list[str] = []
for i, comp in enumerate(no_buses_comps):
placed = False
# Try groups starting at the round-robin offset to keep the spread.
for offset in range(len(platform_groups)):
sig, comps = platform_groups[(i + offset) % len(platform_groups)]
if any(components_conflict(comp, other, platform) for other in comps):
continue
# comps is the same list object stored in grouped_components, so
# this also extends the group in grouped_components.
comps.append(comp)
placed = True
break
if not placed:
leftover.append(comp)
if leftover:
# Components that conflict with every existing group stay together in
# their own no_buses group (they were grouped before, so they don't
# conflict with each other).
grouped_components.setdefault((platform, NO_BUSES_SIGNATURE), []).extend(
leftover
)
groups_to_test = []
individual_tests = set() # Use set to avoid duplicates
+1 -1
View File
@@ -377,6 +377,6 @@ def test_lvgl_generation(
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
in main_cpp
)
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp
assert "show_test_card();" not in main_cpp
assert "set_auto_clear(false);" in main_cpp
@@ -1,4 +1,7 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
api:
+16
View File
@@ -0,0 +1,16 @@
display:
- id: cst9220_display
platform: ili9xxx
model: ili9342
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}
reset_pin: ${disp_reset_pin}
invert_colors: false
touchscreen:
- id: ts_cst9220
i2c_id: i2c_bus
platform: cst9220
display: cst9220_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
@@ -0,0 +1,12 @@
substitutions:
cs_pin: GPIO4
dc_pin: GPIO5
disp_reset_pin: GPIO12
interrupt_pin: GPIO15
reset_pin: GPIO25
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
<<: !include common.yaml
@@ -1,4 +1,7 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
mdns:
@@ -0,0 +1,2 @@
packages:
common: !include common.yaml
@@ -1 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
@@ -1 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
@@ -1 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
+26
View File
@@ -0,0 +1,26 @@
display:
- platform: pixoo
id: pixoo_display
model: 64x64
cs_pin: GPIO5
data_rate: 10MHz
update_interval: 1s
lambda: |-
it.fill(Color(0, 0, 0));
it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0));
it.line(0, 0, 63, 63, Color(0, 255, 0));
- platform: pixoo
id: pixoo_display_pages
model: 64x64
cs_pin: GPIO21
rotation: 90
pages:
- id: pixoo_page
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255));
light:
- platform: pixoo
pixoo_id: pixoo_display
name: Pixoo Brightness
@@ -0,0 +1,4 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
<<: !include common.yaml
+70
View File
@@ -0,0 +1,70 @@
sensor:
- platform: qmi8658
name: "QMI8658 Temperature"
- platform: motion
type: acceleration_x
name: "Accel X"
accuracy_decimals: 4
filters:
- sliding_window_moving_average:
window_size: 4
send_every: 1
- platform: motion
type: acceleration_y
name: "Accel Y"
accuracy_decimals: 4
- platform: motion
type: acceleration_z
name: "Accel Z"
accuracy_decimals: 4
# Gyroscope axes (unit: °/s)
- platform: motion
type: gyroscope_x
name: "Gyro X"
- platform: motion
type: gyroscope_y
name: "Gyro Y"
- platform: motion
type: gyroscope_z
name: "Gyro Z"
- platform: motion
type: angular_rate_x
name: "Angular Rate X"
- platform: motion
type: angular_rate_y
name: "Angular Rate Y"
- platform: motion
type: angular_rate_z
name: "Angular Rate Z"
- platform: motion
type: pitch
name: "Pitch"
- platform: motion
type: roll
name: "Roll"
motion:
- platform: qmi8658
i2c_id: i2c_bus
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
# Accelerometer output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ |
# 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ
accelerometer_odr: 1000HZ
# Gyroscope full-scale range: 16DPS | 32DPS | 64DPS | 128DPS |
# 256DPS | 512DPS | 1024DPS | 2048DPS
gyroscope_range: 2048DPS
# Gyroscope output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ |
# 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ
gyroscope_odr: 1000HZ
axis_map:
x: y
y: x
z: -z
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
+45
View File
@@ -35,6 +35,8 @@ def clear_helpers_cache() -> None:
helpers._get_github_event_data.cache_clear()
helpers._get_changed_files_github_actions.cache_clear()
helpers.get_components_per_integration_fixture.cache_clear()
helpers._get_test_config_components.cache_clear()
helpers._conflict_walk.cache_clear()
@pytest.mark.parametrize(
@@ -1504,6 +1506,8 @@ def fake_components(tmp_path: Path) -> Path:
write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n")
write("broken", "this is not valid python !!!")
helpers.parse_component_metadata.cache_clear()
helpers._get_test_config_components.cache_clear()
helpers._conflict_walk.cache_clear()
return tmp_path
@@ -1624,6 +1628,47 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket(
assert signature.startswith("i2c__conflict")
def test_split_conflicting_groups_seeds_from_test_config(
fake_components: Path, monkeypatch: MonkeyPatch
) -> None:
"""A conflict reachable only via a component's test config splits the group.
``host_user`` declares no static conflict with ``beta``, but its
``test.<platform>.yaml`` pulls in ``beta_variant`` (which AUTO_LOADs
``beta``). On that platform the group must split; on another platform
(no such test config) it must stay together.
"""
monkeypatch.setattr(helpers, "root_path", str(fake_components))
# host_user has no static metadata, but its esp32 test config references
# beta_variant -> AUTO_LOAD beta, which conflicts with alpha.
tests_dir = fake_components / "tests" / "components" / "host_user"
tests_dir.mkdir(parents=True)
(tests_dir / "test.esp32.yaml").write_text("beta_variant:\n")
(fake_components / "esphome" / "components" / "host_user").mkdir()
(
fake_components / "esphome" / "components" / "host_user" / "__init__.py"
).write_text("")
helpers.parse_component_metadata.cache_clear()
helpers._get_test_config_components.cache_clear()
helpers._conflict_walk.cache_clear()
# On esp32, host_user pulls in beta (via its test config) -> conflicts with alpha.
result = helpers.split_conflicting_groups(
{("esp32", "no_buses"): ["alpha", "host_user"]}
)
buckets = list(result.values())
for bucket in buckets:
assert not ({"alpha", "host_user"} <= set(bucket))
# On a platform without that test config, they stay grouped together.
result_other = helpers.split_conflicting_groups(
{("rp2040", "no_buses"): ["alpha", "host_user"]}
)
assert result_other == {("rp2040", "no_buses"): ["alpha", "host_user"]}
# ---------------------------------------------------------------------------
# get_component_test_files / is_validate_only_file
# ---------------------------------------------------------------------------
@@ -0,0 +1,18 @@
esphome:
name: componenttestesp32c61idf
friendly_name: $component_name
esp32:
variant: ESP32C61
flash_size: 8MB
framework:
type: esp-idf
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file
@@ -3,7 +3,7 @@ esphome:
friendly_name: $component_name
ln882x:
board: generic-ln882hki
board: generic-ln882h
logger:
level: VERY_VERBOSE
+47
View File
@@ -36,6 +36,19 @@ from esphome.espidf.framework import (
from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path
@pytest.fixture(autouse=True)
def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin the ESP-IDF install root to a tmp dir for every test.
The default location is the OS user cache dir, so without this any test
that builds framework paths or pre-creates the framework dir would touch
the real ``~/.cache/esphome`` on the developer's machine. Tests that need
to exercise the override or default-resolution logic clear/override the env
themselves.
"""
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install"))
@pytest.mark.parametrize(
("source", "expected"),
[
@@ -791,6 +804,38 @@ def test_get_idf_tools_path_env_override(tmp_path: Path) -> None:
assert _get_idf_tools_path() == Path(override)
@pytest.mark.parametrize("value", ["", " "])
def test_get_idf_tools_path_blank_env_falls_back_to_default(
value: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD.
Path("") would resolve to the working directory, which clean-all could then
delete by accident.
"""
import platformdirs
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert _get_idf_tools_path() == expected
def test_get_idf_tools_path_default_uses_user_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without the env override the install root is the machine-global OS user
cache dir, not the per-config ``<data_dir>/idf``."""
import platformdirs
monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False)
expected = (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
).resolve()
assert _get_idf_tools_path() == expected
def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None:
with patch("pathlib.Path.write_text", side_effect=OSError("denied")):
# write failure is caught and warned, not raised
@@ -908,3 +953,5 @@ def test_check_windows_path_length_long_path_warns(
message = caplog.records[0].getMessage()
assert _LONG_IDF_PATH in message
assert "long path support" in message
# The install is global now; the remedy is the prefix env, not moving the project.
assert "ESPHOME_ESP_IDF_PREFIX" in message
+35 -3
View File
@@ -67,15 +67,23 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
want to verify the PIO-cleanup branch (e.g. test_clean_all,
test_clean_all_partial_exists) install their own inner patch which
stacks on top of this one and wins for the duration of their block.
Also pin ``ESPHOME_ESP_IDF_PREFIX`` to a nonexistent tmp dir for the
same reason: ``clean_all`` removes the now machine-global ESP-IDF
install, which otherwise defaults to the real ``~/.cache/esphome``.
"""
pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent"
idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent"
mock_cfg = MagicMock()
mock_cfg.get.side_effect = lambda section, option: (
str(pio_root / option) if section == "platformio" else ""
)
with patch(
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
with (
patch(
"platformio.project.config.ProjectConfig.get_instance",
return_value=mock_cfg,
),
patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": str(idf_root)}),
):
yield
@@ -990,6 +998,30 @@ def test_clean_all_with_yaml_file(
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_removes_global_idf_install(
mock_core: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""clean_all removes the machine-global native ESP-IDF install dir."""
idf_install = tmp_path / "idf_install"
(idf_install / "frameworks").mkdir(parents=True)
monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install))
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(config_dir)])
assert not idf_install.exists()
assert str(idf_install.resolve()) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_all_with_yaml_build_path(
mock_core: MagicMock,