Merge remote-tracking branch 'origin/dev' into rtttl-play-without-copy

This commit is contained in:
J. Nick Koston
2026-10-12 17:43:04 -10:00
112 changed files with 5228 additions and 359 deletions
+2
View File
@@ -106,6 +106,7 @@ esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bq25186/* @Rapsssito
esphome/components/bq27220/* @rggammon
esphome/components/bq76972/* @limpkin
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
@@ -213,6 +214,7 @@ esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/friedrich/* @rwrozelle
esphome/components/fs3000/* @kahrendt
esphome/components/ft5x06/* @clydebarrow
esphome/components/ft63x6/* @gpambrozio
+21 -6
View File
@@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str:
return str(cg.progmem_string(value))
def flash_compare(config: ConfigType, value: str) -> str:
"""Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266."""
if CORE.is_esp8266:
return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}"
return str(cg.safe_exp(value))
def _default_std_string(
members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str]
) -> list[tuple[str, Any, Any]]:
"""Give ``cg.std_string`` members without a renderer the given default."""
return [
(key, t, fn or (renderer if t is cg.std_string else None))
for key, t, fn in members
]
def literal_with_length(config: ConfigType, value: str) -> str:
"""Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length.
@@ -491,10 +508,7 @@ def apply_action_builder(
statements_spec = [
(
c.target,
[
(key, t, fn or (flash_string if t is cg.std_string else None))
for key, t, fn in c.members
],
_default_std_string(c.members, flash_string),
)
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
]
@@ -563,11 +577,12 @@ def register_apply_condition(
``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an
``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares
against config values, all of which must be present. Write ``== false`` to negate.
String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member.
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
Generates one static predicate and an ``ApplyCondition`` templated on it.
"""
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
members = call.members
members = _default_std_string(call.members, flash_compare)
_check_key_in_schema(name, schema, id_key)
for conf_key, _, _ in members:
_check_key_in_schema(name, schema, conf_key)
+75 -12
View File
@@ -5,8 +5,8 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, ID, Lambda
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG
from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@Rapsssito"]
@@ -14,6 +14,7 @@ DEPENDENCIES = ["i2c"]
DOMAIN = "bq25186"
MULTI_CONF = True
CONF_BQ25186_ID = "bq25186_id"
CONF_CONFIGURE_ON_BOOT = "configure_on_boot"
CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage"
CONF_FAST_CHARGE_CURRENT = "fast_charge_current"
@@ -43,6 +44,7 @@ CONF_WAKE2_TIMER = "wake2_timer"
CONF_ENABLE_PUSH_BUTTON = "enable_push_button"
CONF_SYSTEM_REGULATION = "system_regulation"
CONF_SYS_MODE = "sys_mode"
CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable"
CONF_DISABLE_VDPPM = "disable_vdppm"
CONF_TS_HOT = "ts_hot"
CONF_TS_COLD = "ts_cold"
@@ -56,7 +58,26 @@ CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt"
CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt"
bq25186_ns = cg.esphome_ns.namespace("bq25186")
BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice)
BQ25186Component = bq25186_ns.class_(
"BQ25186Component", cg.PollingComponent, i2c.I2CDevice
)
BQ25186_CHILD_SCHEMA = cv.Schema(
{cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)}
)
_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT")
_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT")
def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression:
_request_binary_sensor_slot(str(hub))
return hub.register_binary_sensor(var)
def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression:
_request_text_sensor_slot(str(hub))
return hub.register_text_sensor(var)
REG_VBAT_CTRL = 0x03
REG_ICHG_CTRL = 0x04
@@ -69,7 +90,6 @@ REG_SYS_REG = 0x0A
REG_TS_CONTROL = 0x0B
REG_MASK_ID = 0x0C
REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1
WATCHDOG_DISABLED = 0x03
TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3}
VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3}
@@ -86,6 +106,22 @@ BATTERY_UVLO_OPTIONS = {
VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1}
RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1}
SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3}
# The I2C watchdog restores the power-on registers when the bus stays quiet for its period
WATCHDOG_OPTIONS = {
"160s_software_reset": 0,
"160s_hardware_reset": 1,
"40s_hardware_reset": 2,
"disabled": 3,
}
WATCHDOG_PERIOD_S = {
"160s_software_reset": 160,
"160s_hardware_reset": 160,
"40s_hardware_reset": 40,
}
# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects
WATCHDOG_15S_PERIOD_S = 15
# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied
POWER_ON_WATCHDOG_PERIOD_S = 160
LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3}
AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3}
INPUT_CURRENT_LIMIT_OPTIONS = {
@@ -146,8 +182,8 @@ def _on_current_step(amps: float) -> float:
# (config key, register, bit shift, validator or enum options, default).
# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are
# driven at runtime, not from configuration.
# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO
# switch drives them.
FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
(
CONF_BATTERY_REGULATION_VOLTAGE,
@@ -179,6 +215,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
(CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"),
(CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False),
(CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"),
(CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"),
(CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"),
(CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False),
(CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"),
@@ -195,6 +232,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
(CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True),
(CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"),
(CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"),
(CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False),
(CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False),
(CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"),
(CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"),
@@ -232,9 +270,6 @@ _ENCODERS: dict[str, Callable[[Any], int]] = {
def pack_registers(config: ConfigType) -> list[int]:
"""Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes."""
regs = [0] * REGISTER_COUNT
# Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running
# watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling.
regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED
for key, register, shift, validator, _ in FIELDS:
value = config[key]
if isinstance(validator, dict):
@@ -259,7 +294,28 @@ def _field_schema(with_defaults: bool) -> cv.Schema:
BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True)
CONFIG_SCHEMA = (
def _check_watchdog(config: ConfigType) -> ConfigType:
"""Only the status poll talks to the chip, so it has to come round before any watchdog does."""
periods = []
if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None:
periods.append(period)
if config[CONF_WATCHDOG_15S_ENABLE]:
periods.append(WATCHDOG_15S_PERIOD_S)
if not config.get(CONF_CONFIGURE_ON_BOOT, True):
periods.append(POWER_ON_WATCHDOG_PERIOD_S)
if not periods:
return config
if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))):
raise cv.Invalid(
f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the "
f"watchdog resets the charger between status polls",
path=[CONF_UPDATE_INTERVAL],
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ25186Component),
@@ -267,7 +323,9 @@ CONFIG_SCHEMA = (
}
)
.extend(BQ25186_CONFIG_SCHEMA)
.extend(i2c.i2c_device_schema(0x6A))
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x6A)),
_check_watchdog,
)
CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id(
@@ -305,7 +363,12 @@ async def bq25186_configure_to_code(
# Options the action leaves out fall back to the component's configuration
path = CORE.config.get_path_for_id(config[CONF_ID])[:-1]
parent_config = CORE.config.get_config_for_path(path)
regs = pack_registers({**parent_config, **config})
merged = {**parent_config, **config}
try:
_check_watchdog(merged)
except cv.Invalid as err:
raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err
regs = pack_registers(merged)
text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs))))
lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void)
@@ -0,0 +1,73 @@
from functools import partial
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PLUG,
DEVICE_CLASS_RUNNING,
)
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor
DEPENDENCIES = ["bq25186"]
BQ25186BinarySensor = bq25186_ns.class_(
"BQ25186BinarySensor", binary_sensor.BinarySensor
)
REG_STAT0 = 0
REG_STAT1 = 1
REG_FLAG0 = 2
# Bit set for each field value that reads as on: a single bit is on when it is 1
_BIT_SET = 0b10
# (key, status register, bit shift, field mask, on-values bitmap, device class)
_FIELDS = (
("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING),
("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING),
("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED),
("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG),
("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED),
("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED),
("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED),
("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED),
("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED),
("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED),
("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED),
("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED),
("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED),
("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED),
("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED),
)
CONFIG_SCHEMA = cv.All(
BQ25186_CHILD_SCHEMA.extend(
{
cv.Optional(key): binary_sensor.binary_sensor_schema(
BQ25186BinarySensor, device_class=device_class
)
for key, *_, device_class in _FIELDS
}
),
cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BQ25186_ID])
sensors = binary_sensor.sub_binary_sensors(config)
for key, reg, shift, mask, match, _ in _FIELDS:
await sensors(
key, partial(register_binary_sensor, hub), reg, shift, mask, match
)
+101 -5
View File
@@ -1,4 +1,10 @@
#include "bq25186.h"
#ifdef USE_BINARY_SENSOR
#include "bq25186_binary_sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "bq25186_text_sensor.h"
#endif
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -7,10 +13,10 @@ namespace esphome::bq25186 {
ESPHOME_LOG_TAG(TAG, "bq25186");
// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode,
// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read
// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset
// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read
static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = {
0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0,
};
bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) {
@@ -31,16 +37,38 @@ bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value
return this->write_register_(reg, reg_value);
}
uint8_t BQ25186Component::expected_register_(size_t index) const {
uint8_t value = this->regs_[index];
#ifdef USE_BQ25186_PG_GPO
if (index == 0 && this->pg_gpo_switch_ != nullptr)
value |= BQ25186_VBAT_CTRL_PG_MODE;
#endif
return value;
}
bool BQ25186Component::config_matches_(const BQ25186StatusRegisters &regs) const {
for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) {
const size_t index = reg - BQ25186_REG_VBAT_CTRL;
const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]);
if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0)
return false;
}
return true;
}
bool BQ25186Component::apply_config(const BQ25186ConfigRegisters &regs) {
// Setup rejects an unknown chip; the action must not program it before that check or after it failed
if (!this->is_ready() || this->is_failed()) {
ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded");
return false;
}
this->regs_ = regs;
this->configured_ = true;
for (size_t i = 0; i < regs.size(); i++) {
const uint8_t reg = BQ25186_REG_VBAT_CTRL + i;
const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]);
const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]);
const uint8_t value = this->expected_register_(i);
const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value);
if (!ok) {
this->status_set_warning(LOG_STR("Configuration partly applied"));
return false;
@@ -68,12 +96,80 @@ void BQ25186Component::setup() {
// A failed write only sets the warning status: the chip has identified itself, so a later
// apply_configuration may retry
if (this->configure_on_boot_)
this->apply_config(this->setup_regs_);
this->apply_config(this->regs_);
#ifdef USE_BQ25186_PG_GPO
if (this->pg_gpo_switch_ != nullptr) {
// The poll below puts the pin in output mode; without a restore mode the switch takes its level from it
auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode();
if (initial.has_value())
this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin
}
#endif
// The poller first runs after a random share of the interval; the entities get a state now
this->update();
}
#ifdef USE_BQ25186_PG_GPO
// The pin mode and level come from the switch, so every poll puts back whatever the chip lost
void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) {
if (this->pg_gpo_switch_ == nullptr)
return;
if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 &&
!this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) {
this->status_set_warning(LOG_STR("PG/GPO pin not in output mode"));
pending = true;
}
const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0;
if (!this->pg_gpo_switch_->has_state()) {
this->pg_gpo_switch_->publish_state(level);
} else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) {
this->status_set_warning(LOG_STR("PG/GPO level write failed"));
pending = true;
}
}
#endif
// Every poll also serves the I2C watchdog, which wants to see traffic within its period
void BQ25186Component::update() {
BQ25186StatusRegisters status;
if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) {
this->status_set_warning(LOG_STR("Status read failed"));
return;
}
// Whatever failed is tried again here, so the warning only stays while something is still wrong
bool pending = false;
// A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register
// behind; either way the configuration is written again
if (this->configured_ && !this->config_matches_(status)) {
ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again");
if (this->apply_config(this->regs_)) {
status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured
} else {
pending = true;
}
}
#ifdef USE_BQ25186_PG_GPO
this->check_pg_gpo_(status, pending);
#endif
if (!pending)
this->status_clear_warning();
#ifdef BQ25186_BINARY_SENSOR_COUNT
for (auto *sensor : this->binary_sensors_)
sensor->on_status(status);
#endif
#ifdef BQ25186_TEXT_SENSOR_COUNT
for (auto *sensor : this->text_sensors_)
sensor->on_status(status);
#endif
}
void BQ25186Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ25186:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
#ifdef USE_BQ25186_PG_GPO
LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_);
#endif
}
} // namespace esphome::bq25186
+54 -3
View File
@@ -2,11 +2,19 @@
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_BQ25186_PG_GPO
#include "esphome/components/switch/switch.h"
#endif
#include <array>
namespace esphome::bq25186 {
static constexpr uint8_t BQ25186_REG_STAT0 = 0x00;
static constexpr uint8_t BQ25186_REG_STAT1 = 0x01;
static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02;
static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03;
static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04;
static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05;
@@ -18,27 +26,70 @@ static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A;
static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B;
static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C;
static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1;
// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration
// registers, which show whether the chip was reset or a write failed
static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1;
// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level
static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80;
static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10;
// Register values for VBAT_CTRL..MASK_ID, packed by code generation
using BQ25186ConfigRegisters = std::array<uint8_t, BQ25186_CONFIG_REGISTER_COUNT>;
using BQ25186StatusRegisters = std::array<uint8_t, BQ25186_STATUS_REGISTER_COUNT>;
class BQ25186Component : public Component, public i2c::I2CDevice {
class BQ25186BinarySensor;
class BQ25186TextSensor;
class BQ25186Component : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_setup_registers(const BQ25186ConfigRegisters &regs) {
this->setup_regs_ = regs;
this->regs_ = regs;
this->configure_on_boot_ = true;
}
bool apply_config(const BQ25186ConfigRegisters &regs);
#ifdef BQ25186_BINARY_SENSOR_COUNT
void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); }
#endif
#ifdef BQ25186_TEXT_SENSOR_COUNT
void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); }
#endif
#ifdef USE_BQ25186_PG_GPO
/// The /PG/GPO pin is put in general purpose output mode when a switch drives it
void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; }
bool write_pg_gpo_level(bool level) {
return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL,
level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0);
}
#endif
/// Write the masked bits of SHIP_RST
bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); }
protected:
bool write_register_(uint8_t reg, uint8_t value);
uint8_t expected_register_(size_t index) const;
bool config_matches_(const BQ25186StatusRegisters &regs) const;
#ifdef USE_BQ25186_PG_GPO
void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending);
#endif
bool update_register_(uint8_t reg, uint8_t mask, uint8_t value);
BQ25186ConfigRegisters setup_regs_{};
#ifdef BQ25186_BINARY_SENSOR_COUNT
StaticVector<BQ25186BinarySensor *, BQ25186_BINARY_SENSOR_COUNT> binary_sensors_;
#endif
#ifdef BQ25186_TEXT_SENSOR_COUNT
StaticVector<BQ25186TextSensor *, BQ25186_TEXT_SENSOR_COUNT> text_sensors_;
#endif
#ifdef USE_BQ25186_PG_GPO
switch_::Switch *pg_gpo_switch_{nullptr};
#endif
BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset
bool configure_on_boot_{false};
bool configured_{false}; // regs_ went to the chip at least once
};
} // namespace esphome::bq25186
@@ -0,0 +1,31 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "bq25186.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::bq25186 {
/// One field of a status register; `match` has a bit set for each field value that reads as on
class BQ25186BinarySensor final : public binary_sensor::BinarySensor {
public:
BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match)
: reg_(reg), shift_(shift), mask_(mask), match_(match) {}
void on_status(const BQ25186StatusRegisters &status) {
const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_;
this->publish_state((this->match_ >> value) & 1);
}
protected:
const uint8_t reg_;
const uint8_t shift_;
const uint8_t mask_;
const uint8_t match_;
};
} // namespace esphome::bq25186
#endif // USE_BINARY_SENSOR
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BUTTON
#include "bq25186.h"
#include "esphome/components/button/button.h"
namespace esphome::bq25186 {
/// Writes one SHIP_RST field when pressed
class BQ25186Button final : public button::Button, public Parented<BQ25186Component> {
public:
BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {}
protected:
void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); }
const uint8_t mask_;
const uint8_t value_;
};
} // namespace esphome::bq25186
#endif // USE_BUTTON
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BQ25186_PG_GPO
#include "bq25186.h"
#include "esphome/components/switch/switch.h"
namespace esphome::bq25186 {
class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented<BQ25186Component> {
protected:
void write_state(bool state) override {
if (this->parent_->write_pg_gpo_level(state))
this->publish_state(state);
}
};
} // namespace esphome::bq25186
#endif // USE_BQ25186_PG_GPO
@@ -0,0 +1,26 @@
#include "bq25186_text_sensor.h"
#ifdef USE_TEXT_SENSOR
#include "esphome/core/progmem.h"
namespace esphome::bq25186 {
// STAT0[6:5] charge status, then STAT1[4:3] TS status
PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled",
"Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)");
void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) {
const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03;
if (value == this->last_)
return;
this->last_ = value;
const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX);
char text[32];
ESPHOME_strncpy_P(text, reinterpret_cast<ESPHOME_PGM_P>(name), sizeof(text));
text[sizeof(text) - 1] = '\0';
this->publish_state(text);
}
} // namespace esphome::bq25186
#endif // USE_TEXT_SENSOR
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_TEXT_SENSOR
#include "bq25186.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::bq25186 {
/// A two-bit status field; `first` is the string table index of the field's value 0
class BQ25186TextSensor final : public text_sensor::TextSensor {
public:
BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {}
void on_status(const BQ25186StatusRegisters &status);
protected:
const uint8_t reg_;
const uint8_t shift_;
const uint8_t first_;
uint8_t last_{0xFF}; // the field value last published
};
} // namespace esphome::bq25186
#endif // USE_TEXT_SENSOR
+52
View File
@@ -0,0 +1,52 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_RESTART,
ENTITY_CATEGORY_CONFIG,
ICON_POWER,
ICON_RESTART,
ICON_RESTART_ALERT,
)
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
DEPENDENCIES = ["bq25186"]
BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button)
# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes
SOFTWARE_RESET_MASK = 0x80
MODE_MASK = 0x60
# (key, SHIP_RST mask, value, device class, icon)
_BUTTONS = (
("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART),
("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER),
("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER),
("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT),
)
CONFIG_SCHEMA = cv.All(
BQ25186_CHILD_SCHEMA.extend(
{
cv.Optional(key): button.button_schema(
BQ25186Button,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=icon,
device_class=device_class,
)
for key, _, _, device_class, icon in _BUTTONS
}
),
cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)),
)
async def to_code(config: ConfigType) -> None:
for key, mask, value, _, _ in _BUTTONS:
if (conf := config.get(key)) is not None:
var = await button.new_button(conf, mask, value)
await cg.register_parented(var, config[CONF_BQ25186_ID])
+27
View File
@@ -0,0 +1,27 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
DEPENDENCIES = ["bq25186"]
CONF_PG_GPO = "pg_gpo"
BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch)
CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend(
{
cv.Required(CONF_PG_GPO): switch.switch_schema(
BQ25186PgGpoSwitch, default_restore_mode="DISABLED"
),
}
)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_BQ25186_PG_GPO")
hub = await cg.get_variable(config[CONF_BQ25186_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_PG_GPO, hub.set_pg_gpo_switch)
+38
View File
@@ -0,0 +1,38 @@
from functools import partial
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor
DEPENDENCIES = ["bq25186"]
BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor)
REG_STAT0 = 0
REG_STAT1 = 1
# (key, status register, bit shift, index of the field's value 0 in the C++ string table)
_FIELDS = (
("charge_status", REG_STAT0, 5, 0),
("ts_status", REG_STAT1, 3, 4),
)
CONFIG_SCHEMA = cv.All(
BQ25186_CHILD_SCHEMA.extend(
{
cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor)
for key, *_ in _FIELDS
}
),
cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BQ25186_ID])
sensors = text_sensor.sub_text_sensors(config)
for key, reg, shift, first in _FIELDS:
await sensors(key, partial(register_text_sensor, hub), reg, shift, first)
+111
View File
@@ -0,0 +1,111 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_ID,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_VOLT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["sensor"]
CODEOWNERS = ["@limpkin"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bq76972"
MULTI_CONF = True
bq76972_ns = cg.esphome_ns.namespace("bq76972")
BQ76972Component = bq76972_ns.class_(
"BQ76972Component", cg.PollingComponent, i2c.I2CDevice
)
CONF_REG_DISABLED = "reg_disabled"
CONF_CRC_ENABLED = "crc_enabled"
CONF_STACK_VOLTAGE = "stack_voltage"
# Ordered as the chip reports them, starting at command 0x68
CONF_TEMPERATURES = [
"internal_temperature",
"cfetoff_temperature",
"dfetoff_temperature",
"alert_temperature",
"ts1_temperature",
"ts2_temperature",
"ts3_temperature",
"hdq_temperature",
"dchg_temperature",
"ddsg_temperature",
]
CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)]
voltage_sensor_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
)
temp_sensor_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ76972Component),
cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean,
cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean,
cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema,
**{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES},
**{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES},
}
)
.extend(i2c.i2c_device_schema(0x08))
.extend(cv.polling_component_schema("10s"))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED]))
cg.add(var.set_reg_disable(config[CONF_REG_DISABLED]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor)
for idx, key in enumerate(CONF_TEMPERATURES):
if (conf := config.get(key)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(var.set_temperature_sensor(sens, idx))
for idx, key in enumerate(CONF_CELL_VOLTAGES):
if (conf := config.get(key)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(var.set_cell_sensor(sens, idx))
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(BQ76972Component),
cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address),
},
key=CONF_ADDRESS,
)
automation.register_apply_action(
"bq76972.program_i2c_address",
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA,
automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8),
)
+298
View File
@@ -0,0 +1,298 @@
#include "bq76972.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bq76972 {
ESPHOME_LOG_TAG(TAG, "bq76972");
static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte
static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40;
static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length
static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12;
static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14;
static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34;
static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68;
static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090;
static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092;
static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236;
static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237;
static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A;
static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC;
// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N
static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9;
static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default
static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07;
// 16 words with a CRC byte after every data byte
static constexpr size_t BQ76972_MAX_TRANSFER = 64;
static constexpr uint8_t BQ76972_MAX_POLLS = 50;
static float raw_to_celsius(uint16_t raw) { return static_cast<int16_t>(raw) * 0.1f - 273.15f; }
bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) {
if (!this->crc_mode_)
return this->read_bytes(start_register, data, len);
// With CRC enabled, every data byte is followed by a CRC byte
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
if (len == 0 || len * 2 > buffer.size())
return false;
if (!this->read_bytes(start_register, buffer.data(), len * 2))
return false;
// The first CRC also covers the write address, register and read address
const uint8_t crc0_input[4] = {static_cast<uint8_t>(this->address_ << 1), start_register,
static_cast<uint8_t>((this->address_ << 1) | 1), buffer[0]};
if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) {
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register);
return false;
}
data[0] = buffer[0];
for (size_t i = 1; i < len; ++i) {
if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) {
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast<unsigned>(i));
return false;
}
data[i] = buffer[i * 2];
}
return true;
}
bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) {
if (!this->crc_mode_)
return this->write_bytes(start_register, data, len);
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
if (len == 0 || len * 2 > buffer.size())
return false;
// The first CRC also covers the write address and register
const uint8_t crc0_input[3] = {static_cast<uint8_t>(this->address_ << 1), start_register, data[0]};
buffer[0] = data[0];
buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true);
for (size_t i = 1; i < len; ++i) {
buffer[i * 2] = data[i];
buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true);
}
return this->write_bytes(start_register, buffer.data(), len * 2);
}
bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) {
std::array<uint8_t, BQ76972_MAX_TRANSFER / 2> bytes;
if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2))
return false;
for (size_t i = 0; i < num_words; i++)
data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]);
return true;
}
bool BQ76972Component::send_subcommand_(uint16_t subcommand) {
const uint8_t bytes[2] = {static_cast<uint8_t>(subcommand & 0xFF), static_cast<uint8_t>(subcommand >> 8)};
return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2);
}
bool BQ76972Component::wait_for_subcommand_() {
uint8_t bytes[2];
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2))
return false;
if (bytes[0] != 0xFF && bytes[1] != 0xFF)
return true;
delay(1);
}
return false;
}
bool BQ76972Component::wait_for_cfgupdate_() {
uint16_t status;
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1))
return false;
if (status & 0x0001)
return true;
delay(1);
}
return false;
}
bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) {
return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() &&
this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len);
}
bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) {
if (!this->send_subcommand_(subcommand))
return false;
if (len == 0)
return true;
if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len))
return false;
// Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data
uint8_t sum = static_cast<uint8_t>(subcommand & 0xFF) + static_cast<uint8_t>(subcommand >> 8);
for (size_t i = 0; i < len; ++i)
sum += data[i];
const uint8_t tail[2] = {static_cast<uint8_t>(~sum), static_cast<uint8_t>(4 + len)};
return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2);
}
bool BQ76972Component::enter_cfg_update_() {
if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0))
return false;
delay(10);
return this->wait_for_cfgupdate_();
}
bool BQ76972Component::exit_cfg_update_() {
return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0);
}
bool BQ76972Component::regs_disabled_(bool &out) {
uint8_t reg12;
uint8_t reg0;
if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, &reg12, 1) ||
!this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, &reg0, 1))
return false;
out = reg12 == 0 && (reg0 & 0x01) == 0;
return true;
}
const LogString *BQ76972Component::configure_() {
uint16_t internal_temp;
if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1))
return LOG_STR(ESP_LOG_MSG_COMM_FAIL);
// Only write the regulator config when it differs, see
// https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout
bool regs_disabled = true;
if (this->reg_disable_ && !this->regs_disabled_(regs_disabled))
return LOG_STR("Reading regulator config failed");
const bool write_regs = !regs_disabled;
bool write_thermistors = false;
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr)
write_thermistors = true;
}
if (write_regs || write_thermistors) {
if (!this->enter_cfg_update_())
return LOG_STR("Entering config update mode failed");
const LogString *error = this->write_config_(write_regs);
// The chip stops measuring in config update mode, so leave it even after a failed write
if (!this->exit_cfg_update_() && error == nullptr)
error = LOG_STR("Exiting config update mode failed");
if (error != nullptr)
return error;
if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled))
return LOG_STR("Disabling regulators failed");
}
ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp));
return nullptr;
}
const LogString *BQ76972Component::write_config_(bool write_regs) {
if (write_regs) {
const uint8_t zero = 0;
if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) ||
!this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1))
return LOG_STR("Writing regulator config failed");
}
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr)
continue;
if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1))
return LOG_STR("Writing thermistor pin config failed");
}
return nullptr;
}
void BQ76972Component::setup() {
if (const LogString *error = this->configure_(); error != nullptr)
this->mark_failed(error);
}
void BQ76972Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ76972:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG,
" CRC Mode: %s\n"
" Regulators disabled: %s",
this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_));
}
void BQ76972Component::program_i2c_address(uint8_t address) {
if (this->is_failed())
return;
const uint8_t shifted = address << 1;
if (!this->enter_cfg_update_()) {
this->status_set_warning(LOG_STR("Programming I2C address failed"));
return;
}
// The new address only takes effect with the swap, so after a failed write the chip still answers at the
// old one and must still be taken out of config update mode
const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) &&
this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0);
if (written) {
delay(10);
this->set_i2c_address(address);
}
if (!this->exit_cfg_update_() || !written) {
this->status_set_warning(LOG_STR("Programming I2C address failed"));
return;
}
ESP_LOGI(TAG, "I2C address set to 0x%02X", address);
}
template<size_t N> static bool any_set(const std::array<sensor::Sensor *, N> &sensors) {
for (auto *sensor : sensors) {
if (sensor != nullptr)
return true;
}
return false;
}
void BQ76972Component::update() {
// Each group publishes as soon as its read succeeds, so one failed read does not hold back the others
bool ok = true;
uint16_t words[BQ76972_NUM_CELLS];
if (this->stack_voltage_sensor_ != nullptr) {
if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) {
if (words[0] != 0)
this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units
} else {
ok = false;
}
}
if (any_set(this->cell_sensors_)) {
if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) {
for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) {
if (this->cell_sensors_[i] != nullptr && words[i] != 0)
this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV
}
} else {
ok = false;
}
}
if (any_set(this->temperature_sensors_)) {
if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) {
for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) {
if (this->temperature_sensors_[i] != nullptr)
this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i]));
}
} else {
ok = false;
}
}
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
}
}
} // namespace esphome::bq76972
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include <array>
namespace esphome::bq76972 {
static constexpr size_t BQ76972_NUM_CELLS = 16;
static constexpr size_t BQ76972_NUM_TEMPERATURES = 10;
class BQ76972Component final : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; }
void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; }
void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; }
void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; }
void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; }
void program_i2c_address(uint8_t address);
protected:
bool read_block_(uint8_t start_register, uint8_t *data, size_t len);
bool write_block_(uint8_t start_register, const uint8_t *data, size_t len);
bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words);
bool send_subcommand_(uint16_t subcommand);
bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len);
bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len);
bool wait_for_subcommand_();
bool wait_for_cfgupdate_();
bool enter_cfg_update_();
bool exit_cfg_update_();
bool regs_disabled_(bool &out);
const LogString *configure_();
const LogString *write_config_(bool write_regs);
bool reg_disable_{false};
bool crc_mode_{false};
sensor::Sensor *stack_voltage_sensor_{nullptr};
std::array<sensor::Sensor *, BQ76972_NUM_CELLS> cell_sensors_{};
std::array<sensor::Sensor *, BQ76972_NUM_TEMPERATURES> temperature_sensors_{};
};
} // namespace esphome::bq76972
+1
View File
@@ -59,6 +59,7 @@ CONF_REQUEST_HEADERS = "request_headers"
CONF_ROLE = "role"
CONF_ROWS = "rows"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SERIAL_NUMBER = "serial_number"
CONF_SHA256 = "sha256"
CONF_SLOT = "slot"
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
+1
View File
@@ -0,0 +1 @@
DOMAIN = "friedrich"
+32
View File
@@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components import climate_ir, remote_base
import esphome.config_validation as cv
from esphome.const import CONF_MODEL, CONF_SENSOR
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@rwrozelle"]
friedrich_ns = cg.esphome_ns.namespace("friedrich")
FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
Model = friedrich_ns.enum("Model", is_class=True)
MODELS = {
"MW12Y3H": Model.MODEL_MW12Y3H,
}
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend(
{
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
cv.Optional(CONF_SENSOR): cv.invalid(
"'sensor' is not supported by the Friedrich climate component"
),
}
)
async def to_code(config: ConfigType) -> None:
remote_base.request_protocol("aeha") # used from C++
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_model(config[CONF_MODEL]))
+234
View File
@@ -0,0 +1,234 @@
#include "friedrich.h"
#include <cmath>
#include "esphome/components/remote_base/aeha_protocol.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::friedrich {
// clang-format off
/*
* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation.
* AEHA
* Byte0 Fixed = 0x00
* Byte1 Fixed = 0x08
* Byte2 Fixed = 0x08
* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F
* Byte4 Checksum when command, else Fixed = 0x90
* Byte5 N/A when command, else Fixed = 0x0C
* Byte6 N/A when command, else Temp(F), add 0x80 for On
* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed)
* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing
* Byte9 N/A when command, else Fixed = 0x00 // not interested
* Byte10 N/A when command, else Fixed = 0x00 // not interested
* Byte11 N/A when command, else Fixed = 0x00 // not interested
* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04)
* Byte13 N/A when command, else CheckSum
*/
// clang-format on
ESPHOME_LOG_TAG(TAG, "friedrich");
static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64;
static constexpr uint8_t TEMP_DEFAULT = 72;
static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH;
static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5;
static constexpr uint16_t CARRIER_ADDRESS = 0x28C6;
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
static constexpr uint8_t BYTE3_POWER_OFF = 0x40;
static constexpr uint8_t BYTE6_POWER_ON = 0x80;
static constexpr uint8_t BYTE7_MODE_AUTO = 0x00;
static constexpr uint8_t BYTE8_FAN_AUTO = 0x00;
static constexpr uint8_t BYTE8_FAN_SWING = 0x08;
// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state
static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = {
0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00};
// The off command, including its checksum (255 - byte 3)
static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF,
255 - BYTE3_POWER_OFF};
struct Code {
uint8_t value; // a climate enum, all of which are uint8_t
uint8_t code;
};
static constexpr Code MODES[] PROGMEM = {
{climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO},
{climate::CLIMATE_MODE_COOL, 0x80},
{climate::CLIMATE_MODE_DRY, 0x40},
{climate::CLIMATE_MODE_FAN_ONLY, 0xC0},
{climate::CLIMATE_MODE_HEAT, 0x20},
};
static constexpr Code FANS[] PROGMEM = {
{climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80},
{climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0},
{climate::CLIMATE_FAN_QUIET, 0x20},
};
// The entry whose value (or code) matches key. Entries are copied out of PROGMEM.
template<size_t N> static optional<Code> find_entry(const Code (&table)[N], uint8_t key, bool match_code) {
for (const Code &slot : table) {
Code entry;
progmem_memcpy(&entry, &slot, sizeof(entry));
if ((match_code ? entry.code : entry.value) == key)
return entry;
}
return {};
}
// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse.
static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; }
static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) {
for (uint8_t i = from; i <= to; i++) {
if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i]))
return false;
}
return true;
}
/*
* https://gist.github.com/GeorgeDewar/11171561
* 1. Reverse (flip) bytes 6 - 13)
* 2. Sum those bytes
* 3. (208 - sum) % 256
* 4. Reverse (flip) bytes of result
*/
static uint8_t checksum_state(const uint8_t *data) {
uint8_t sum = 0;
for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) {
sum += reverse_bits(data[i]);
}
return reverse_bits(static_cast<uint8_t>(208 - sum));
}
static const LogString *model_to_string(Model model) {
switch (model) {
case Model::MODEL_MW12Y3H:
default:
return LOG_STR("MW12Y3H");
}
}
climate::ClimateTraits FriedrichClimate::traits() {
auto traits = ClimateIR::traits();
traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT);
return traits;
}
void FriedrichClimate::setup() {
ClimateIR::setup();
// The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version.
if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) {
this->target_temperature = TEMP_DEFAULT;
}
this->target_temperature = std::min<float>(this->target_temperature, TEMP_MAX);
}
void FriedrichClimate::dump_config() {
// ClimateIR::dump_config() is not called: it prints the temperature range in Celsius
LOG_CLIMATE("", "Friedrich Climate", this);
ESP_LOGCONFIG(TAG,
" Temperature range: %u to %u °F\n"
" Model: %s",
TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_)));
}
void FriedrichClimate::transmit_state() {
if (this->mode == climate::CLIMATE_MODE_OFF) {
this->transmit_off_();
return;
}
ESP_LOGV(TAG, "Transmit state");
// Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it,
// so the state published after this call matches the frame sent.
const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT;
uint8_t temperature = (uint8_t) roundf(clamp<float>(this->target_temperature, effective_min, TEMP_MAX));
temperature += temperature % 2;
this->target_temperature = temperature;
const auto mode = find_entry(MODES, this->mode, false);
const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false);
uint8_t frame[STATE_MESSAGE_LENGTH];
progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame));
frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP);
frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO;
frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO;
if (this->swing_mode != climate::CLIMATE_SWING_OFF)
frame[8] |= BYTE8_FAN_SWING;
frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame);
this->transmit_(frame, sizeof(frame));
}
void FriedrichClimate::transmit_off_() {
ESP_LOGV(TAG, "Transmit off");
uint8_t frame[STATE_MESSAGE_LENGTH_UTIL];
progmem_memcpy(frame, OFF_FRAME, sizeof(frame));
this->transmit_(frame, sizeof(frame));
}
void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) {
ESP_LOGV(TAG, "Transmit message");
auto transmit = this->transmitter_->transmit();
auto *dst = transmit.get_data();
dst->set_carrier_frequency(CARRIER_FREQUENCY);
remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len);
transmit.perform();
}
bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) {
ESP_LOGV(TAG, "Received message");
const auto odata = remote_base::AEHAProtocol().decode(src);
if (!odata.has_value())
return false;
const auto &d = odata->data;
// Other vendors also use AEHA; only accept frames with our address and fixed header bytes.
if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2))
return false;
if (d.size() == STATE_MESSAGE_LENGTH_UTIL) {
// Not looking for other types of messages
if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3])
return false;
ESP_LOGV(TAG, "Received off message");
this->mode = climate::CLIMATE_MODE_OFF;
this->publish_state();
return true;
}
if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) ||
!matches_template(d.data(), 3, 5))
return false;
// Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY)
const auto mode = find_entry(MODES, d[7], true);
if (!mode.has_value()) {
ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]);
return false;
}
this->mode = static_cast<climate::ClimateMode>(mode->value);
// State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is
// stripped rather than acted on
const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON;
const uint8_t temp_index = reverse_nibble(temp_code);
if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP)
this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP;
this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true);
this->fan_mode = fan.has_value() ? static_cast<climate::ClimateFanMode>(fan->value) : climate::CLIMATE_FAN_AUTO;
this->publish_state();
return true;
}
} // namespace esphome::friedrich
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
namespace esphome::friedrich {
// Extension point for other units: add new models here and branch on model_ where they differ.
// The default must stay MW12Y3H so existing configurations do not change.
enum class Model : uint8_t {
MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit
};
class FriedrichClimate : public climate_ir::ClimateIR {
public:
// The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values.
static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating
static constexpr uint8_t TEMP_MAX = 88;
static constexpr uint8_t TEMP_STEP = 2;
static constexpr uint8_t STATE_MESSAGE_LENGTH = 14;
FriedrichClimate()
: ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {}
void set_model(Model model) { this->model_ = model; }
protected:
void setup() override;
void dump_config() override;
climate::ClimateTraits traits() override;
/// Transmit via IR the state of this climate controller.
void transmit_state() override;
/// Transmit via IR power off command.
void transmit_off_();
/// Parse incoming message
bool on_receive(remote_base::RemoteReceiveData src) override;
/// Transmit message as IR pulses
void transmit_(const uint8_t *data, uint8_t len);
Model model_{Model::MODEL_MW12Y3H};
};
} // namespace esphome::friedrich
@@ -24,9 +24,13 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned
return; \
}
// Reset sequence (Goodix GT911 datasheet): INT held low while RST is released selects address 0x5D, then
// T3 + T4 pass before INT is handed back to the chip as its interrupt output
static constexpr uint32_t RESET_LOW_MS = 10;
static constexpr uint32_t RESET_SETTLE_MS = 5 + 50 + 1;
void GT911Touchscreen::setup() {
if (this->reset_pin_ != nullptr) {
// temporarily set the interrupt pin to output to control address selection
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
if (this->interrupt_pin_ != nullptr) {
@@ -34,20 +38,19 @@ void GT911Touchscreen::setup() {
this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT);
this->interrupt_pin_->digital_write(false);
}
delay(2);
this->reset_pin_->digital_write(true);
// wait at least T3+T4 ms as per the datasheet
this->set_timeout(5 + 50 + 1, [this] { this->setup_internal_(); });
this->set_timeout(RESET_LOW_MS, [this] {
this->reset_pin_->digital_write(true);
this->set_timeout(RESET_SETTLE_MS, [this] { this->setup_internal_(); });
});
return;
}
this->setup_internal_();
}
void GT911Touchscreen::setup_internal_() {
if (this->interrupt_pin_ != nullptr) {
if (this->interrupt_pin_->is_internal())
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
}
// Expander pins must leave output mode too, or the chip cannot drive its interrupt line
if (this->interrupt_pin_ != nullptr)
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
uint8_t data[4];
i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES));
@@ -72,7 +75,7 @@ void GT911Touchscreen::setup_internal_() {
ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s",
active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW"));
} else {
// IO expander pin: leave as output for configuration only.
// IO expander pin: no hardware interrupt, the touch state is polled.
ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s",
active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW"));
}
@@ -14,17 +14,8 @@ class GT911ButtonListener {
class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
/// @brief Initialize the GT911 touchscreen.
///
/// If @ref reset_pin_ is set, the touchscreen will be hardware reset,
/// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout()
/// to allow the device to stabilize after reset.
///
/// If @ref interrupt_pin_ is set, it will be temporarily configured during reset
/// to control I2C address selection.
///
/// After the timeout, or immediately if no reset is performed, @ref setup_internal_()
/// is called to complete the initialization.
/// With a reset pin the chip is reset with the interrupt pin held low, which selects address 0x5D, and the
/// rest of the setup runs from timeouts once the chip is ready. Without one setup_internal_() runs at once.
void setup() override;
void dump_config() override;
bool can_proceed() override { return this->setup_done_; }
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import text_sensor
from esphome.components.const import CONF_SERIAL_NUMBER
import esphome.config_validation as cv
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
from esphome.types import ConfigType
@@ -9,7 +10,6 @@ from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
CONF_DOOR_STATE = "door_state"
CONF_SERIAL_NUMBER = "serial_number"
HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_(
"HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component
+107 -29
View File
@@ -13,6 +13,24 @@ static const uint8_t INA3221_REGISTER_CHANNEL2_SHUNT_VOLTAGE = 0x03;
static const uint8_t INA3221_REGISTER_CHANNEL2_BUS_VOLTAGE = 0x04;
static const uint8_t INA3221_REGISTER_CHANNEL3_SHUNT_VOLTAGE = 0x05;
static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06;
#ifdef USE_INA3221_ALERT_LIMITS
static constexpr uint8_t INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT = 0x07;
static constexpr uint8_t INA3221_REGISTER_CHANNEL1_WARNING_ALERT = 0x0A;
#endif
#ifdef USE_INA3221_SUMMATION
static constexpr uint8_t INA3221_REGISTER_SHUNT_VOLTAGE_SUM = 0x0D;
static constexpr uint8_t INA3221_REGISTER_MASK_ENABLE = 0x0F;
#endif
// Configuration register: bit 15 resets the chip, bits 14..12 enable channels 1..3 (the same bit
// positions select channels for summation in the mask/enable register) and bits 2..0 set the mode.
// Code generation fills in the averaging and conversion time fields.
static constexpr uint16_t INA3221_CONFIG_RESET = 0x8000;
static constexpr uint16_t INA3221_CONFIG_CHANNEL1_ENABLE = 0x4000;
static constexpr uint16_t INA3221_CONFIG_CHANNEL_MASK = 0x7000;
static constexpr uint16_t INA3221_CONFIG_MODE_MASK = 0x0007;
static constexpr uint32_t READ_TIMEOUT_ID = 0;
// Addresses:
// A0 = GND -> 0x40
@@ -21,37 +39,38 @@ static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06;
// A0 = SCL -> 0x43
void INA3221Component::setup() {
// Config Register
// 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset)
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, 0x8000)) {
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) {
this->mark_failed();
return;
}
delay(1);
uint16_t config = 0;
// 0b0xxx000000000000 << 12 Channel Enables (1 -> ON)
if (this->channels_[0].exists()) {
config |= 0b0100000000000000;
}
if (this->channels_[1].exists()) {
config |= 0b0010000000000000;
}
if (this->channels_[2].exists()) {
config |= 0b0001000000000000;
}
// 0b0000xxx000000000 << 9 Averaging Mode (0 -> 1 sample, 111 -> 1024 samples)
config |= 0b0000000000000000;
// 0b0000000xxx000000 << 6 Bus Voltage Conversion time (100 -> 1.1ms, 111 -> 8.244 ms)
config |= 0b0000000111000000;
// 0b0000000000xxx000 << 3 Shunt Voltage Conversion time (same as above)
config |= 0b0000000000111000;
// 0b0000000000000xxx << 0 Operating mode (111 -> Shunt and bus, continuous)
config |= 0b0000000000000111;
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, config)) {
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) {
this->mark_failed();
return;
}
#ifdef USE_INA3221_ALERT_LIMITS
for (uint8_t i = 0; i < 3; i++) {
const INA3221Channel &channel = this->channels_[i];
if ((channel.critical_limit_ != 0 &&
!this->write_byte_16(INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT + i, channel.critical_limit_)) ||
(channel.warning_limit_ != 0 &&
!this->write_byte_16(INA3221_REGISTER_CHANNEL1_WARNING_ALERT + i, channel.warning_limit_))) {
this->mark_failed();
return;
}
}
#endif
#ifdef USE_INA3221_SUMMATION
// Sum the shunt voltages of every enabled channel
if (this->sum_shunt_voltage_sensor_ != nullptr &&
!this->write_byte_16(INA3221_REGISTER_MASK_ENABLE, this->config_ & INA3221_CONFIG_CHANNEL_MASK)) {
this->mark_failed();
return;
}
#endif
}
void INA3221Component::dump_config() {
@@ -61,6 +80,10 @@ void INA3221Component::dump_config() {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG, " Config register: 0x%04X", this->config_);
if (this->single_shot_wait_ms_ != 0) {
ESP_LOGCONFIG(TAG, " Single-shot conversion: %u ms", this->single_shot_wait_ms_);
}
LOG_SENSOR(" ", "Bus Voltage #1", this->channels_[0].bus_voltage_sensor_);
LOG_SENSOR(" ", "Shunt Voltage #1", this->channels_[0].shunt_voltage_sensor_);
@@ -74,6 +97,11 @@ void INA3221Component::dump_config() {
LOG_SENSOR(" ", "Shunt Voltage #3", this->channels_[2].shunt_voltage_sensor_);
LOG_SENSOR(" ", "Current #3", this->channels_[2].current_sensor_);
LOG_SENSOR(" ", "Power #3", this->channels_[2].power_sensor_);
#ifdef USE_INA3221_SUMMATION
LOG_SENSOR(" ", "Sum Shunt Voltage", this->sum_shunt_voltage_sensor_);
LOG_SENSOR(" ", "Sum Current", this->sum_current_sensor_);
LOG_SENSOR(" ", "Sum Power", this->sum_power_sensor_);
#endif
}
inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel * 2; }
@@ -81,11 +109,36 @@ inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel
inline uint8_t ina3221_shunt_voltage_register(int channel) { return 0x01 + channel * 2; }
void INA3221Component::update() {
if (this->single_shot_wait_ms_ == 0) {
this->read_data_();
return;
}
// In single-shot mode, writing the configuration register starts one conversion of every channel
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) {
this->status_set_warning();
return;
}
this->set_timeout(READ_TIMEOUT_ID, this->single_shot_wait_ms_, [this]() { this->read_data_(); });
}
void INA3221Component::read_data_() {
#ifdef USE_INA3221_SUMMATION
// The software sums need every enabled channel's readings, not only the ones with their own sensors
const bool sum_all = this->sum_current_sensor_ != nullptr || this->sum_power_sensor_ != nullptr;
float total_current_a = 0.0f;
float total_power_w = 0.0f;
#else
constexpr bool sum_all = false;
#endif
for (int i = 0; i < 3; i++) {
if ((this->config_ & (INA3221_CONFIG_CHANNEL1_ENABLE >> i)) == 0) {
continue;
}
INA3221Channel &channel = this->channels_[i];
float bus_voltage_v = NAN, current_a = NAN;
uint16_t raw;
if (channel.should_measure_bus_voltage()) {
if (sum_all || channel.should_measure_bus_voltage()) {
if (!this->read_byte_16(ina3221_bus_voltage_register(i), &raw)) {
this->status_set_warning();
return;
@@ -94,7 +147,7 @@ void INA3221Component::update() {
if (channel.bus_voltage_sensor_ != nullptr)
channel.bus_voltage_sensor_->publish_state(bus_voltage_v);
}
if (channel.should_measure_shunt_voltage()) {
if (sum_all || channel.should_measure_shunt_voltage()) {
if (!this->read_byte_16(ina3221_shunt_voltage_register(i), &raw)) {
this->status_set_warning();
return;
@@ -109,17 +162,42 @@ void INA3221Component::update() {
if (channel.power_sensor_ != nullptr) {
channel.power_sensor_->publish_state(bus_voltage_v * current_a);
}
#ifdef USE_INA3221_SUMMATION
if (sum_all) {
total_current_a += current_a;
total_power_w += bus_voltage_v * current_a;
}
#endif
}
#ifdef USE_INA3221_SUMMATION
if (this->sum_shunt_voltage_sensor_ != nullptr) {
uint16_t raw;
if (!this->read_byte_16(INA3221_REGISTER_SHUNT_VOLTAGE_SUM, &raw)) {
this->status_set_warning();
return;
}
// 40 uV per step, held in bits 15..1
this->sum_shunt_voltage_sensor_->publish_state(int16_t(raw) * 40.0f / 2.0f / 1000000.0f);
}
if (this->sum_current_sensor_ != nullptr) {
this->sum_current_sensor_->publish_state(total_current_a);
}
if (this->sum_power_sensor_ != nullptr) {
this->sum_power_sensor_->publish_state(total_power_w);
}
#endif
}
void INA3221Component::on_powerdown() {
// Mode 0 powers the chip down; setup() resets it on the next boot
this->write_byte_16(INA3221_REGISTER_CONFIG, static_cast<uint16_t>(this->config_ & ~INA3221_CONFIG_MODE_MASK));
}
void INA3221Component::set_shunt_resistance(int channel, float resistance_ohm) {
this->channels_[channel].shunt_resistance_ = resistance_ohm;
}
bool INA3221Component::INA3221Channel::exists() {
return this->bus_voltage_sensor_ != nullptr || this->shunt_voltage_sensor_ != nullptr ||
this->current_sensor_ != nullptr || this->power_sensor_ != nullptr;
}
bool INA3221Component::INA3221Channel::should_measure_shunt_voltage() {
return this->shunt_voltage_sensor_ != nullptr || this->current_sensor_ != nullptr || this->power_sensor_ != nullptr;
}
+24 -1
View File
@@ -7,10 +7,17 @@
namespace esphome::ina3221 {
class INA3221Component final : public PollingComponent, public i2c::I2CDevice {
#ifdef USE_INA3221_SUMMATION
SUB_SENSOR(sum_shunt_voltage)
SUB_SENSOR(sum_current)
SUB_SENSOR(sum_power)
#endif
public:
void setup() override;
void dump_config() override;
void update() override;
void on_powerdown() override;
void set_bus_voltage_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].bus_voltage_sensor_ = obj; }
void set_shunt_voltage_sensor(int channel, sensor::Sensor *obj) {
@@ -19,19 +26,35 @@ class INA3221Component final : public PollingComponent, public i2c::I2CDevice {
void set_current_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].current_sensor_ = obj; }
void set_power_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].power_sensor_ = obj; }
void set_shunt_resistance(int channel, float resistance_ohm);
/// Configuration register value built by code generation: channel enables, averaging, conversion times, mode
void set_config_register(uint16_t config) { this->config_ = config; }
/// Time one single-shot conversion of every enabled channel takes; zero means continuous mode
void set_single_shot_wait_ms(uint16_t wait_ms) { this->single_shot_wait_ms_ = wait_ms; }
#ifdef USE_INA3221_ALERT_LIMITS
void set_warning_limit(int channel, uint16_t reg) { this->channels_[channel].warning_limit_ = reg; }
void set_critical_limit(int channel, uint16_t reg) { this->channels_[channel].critical_limit_ = reg; }
#endif
protected:
void read_data_();
struct INA3221Channel {
float shunt_resistance_{0.1f};
sensor::Sensor *bus_voltage_sensor_{nullptr};
sensor::Sensor *shunt_voltage_sensor_{nullptr};
sensor::Sensor *current_sensor_{nullptr};
sensor::Sensor *power_sensor_{nullptr};
#ifdef USE_INA3221_ALERT_LIMITS
// Alert limit register values; zero keeps the chip default, which never trips
uint16_t warning_limit_{0};
uint16_t critical_limit_{0};
#endif
bool exists();
bool should_measure_shunt_voltage();
bool should_measure_bus_voltage();
} channels_[3];
uint16_t config_{0};
uint16_t single_shot_wait_ms_{0};
};
} // namespace esphome::ina3221
+187 -37
View File
@@ -1,5 +1,8 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME
import esphome.config_validation as cv
from esphome.const import (
CONF_BUS_VOLTAGE,
@@ -8,6 +11,7 @@ from esphome.const import (
CONF_POWER,
CONF_SHUNT_RESISTANCE,
CONF_SHUNT_VOLTAGE,
CONF_UPDATE_INTERVAL,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_POWER,
DEVICE_CLASS_VOLTAGE,
@@ -23,69 +27,195 @@ DEPENDENCIES = ["i2c"]
CONF_CHANNEL_1 = "channel_1"
CONF_CHANNEL_2 = "channel_2"
CONF_CHANNEL_3 = "channel_3"
CONF_CONTINUOUS_MODE = "continuous_mode"
CONF_WARNING_CURRENT_LIMIT = "warning_current_limit"
CONF_CRITICAL_CURRENT_LIMIT = "critical_current_limit"
CONF_SUM_SHUNT_VOLTAGE = "sum_shunt_voltage"
CONF_SUM_CURRENT = "sum_current"
CONF_SUM_POWER = "sum_power"
CHANNELS = (CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3)
# Configuration register layout: channel enables in bits 14..12, averaging in 11..9,
# bus conversion time in 8..6, shunt conversion time in 5..3, operating mode in 2..0
CHANNEL_ENABLE_BITS = (1 << 14, 1 << 13, 1 << 12)
ADC_AVERAGING_SHIFT = 9
ADC_TIME_BUS_SHIFT = 6
ADC_TIME_SHUNT_SHIFT = 3
MODE_SINGLE_SHOT = 0b011
MODE_CONTINUOUS = 0b111
# Register field value by sample count and by conversion time in microseconds
ADC_AVERAGING = {1: 0, 4: 1, 16: 2, 64: 3, 128: 4, 256: 5, 512: 6, 1024: 7}
ADC_TIMES_US = {140: 0, 204: 1, 332: 2, 588: 3, 1100: 4, 2116: 5, 4156: 6, 8244: 7}
# Alert limits are shunt voltages in 40 uV steps, held in bits 15..3
ALERT_LIMIT_LSB_V = 40e-6
ALERT_LIMIT_MAX_V = 0x0FFF * ALERT_LIMIT_LSB_V
# Headroom on top of the computed conversion time before the results are read
SINGLE_SHOT_MARGIN_MS = 5
ina3221_ns = cg.esphome_ns.namespace("ina3221")
INA3221Component = ina3221_ns.class_(
"INA3221Component", cg.PollingComponent, i2c.I2CDevice
)
INA3221_CHANNEL_SCHEMA = cv.Schema(
{
cv.Optional(CONF_BUS_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_SHUNT_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All(
cv.resistance, cv.Range(min=0.0, max=32.0)
),
}
VOLTAGE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
)
CURRENT_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
)
POWER_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
)
CURRENT_LIMIT_SCHEMA = cv.All(cv.current, cv.Range(min=0.0, min_included=False))
def validate_adc_time(value: Any) -> int:
value = cv.positive_time_period_microseconds(value).total_microseconds
return cv.one_of(*ADC_TIMES_US, int=True)(value)
def validate_alert_limits(config: ConfigType) -> ConfigType:
shunt = config[CONF_SHUNT_RESISTANCE]
for key in (CONF_WARNING_CURRENT_LIMIT, CONF_CRITICAL_CURRENT_LIMIT):
if (limit := config.get(key)) is None:
continue
if limit * shunt > ALERT_LIMIT_MAX_V:
raise cv.Invalid(
f"{key} times shunt_resistance exceeds the "
f"{ALERT_LIMIT_MAX_V * 1000:.1f} mV alert range",
path=[key],
)
if alert_limit_register(limit, shunt) == 0:
raise cv.Invalid(
f"{key} times shunt_resistance is below the "
f"{ALERT_LIMIT_LSB_V * 1e6:.0f} µV alert resolution",
path=[key],
)
return config
INA3221_CHANNEL_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_BUS_VOLTAGE): VOLTAGE_SCHEMA,
cv.Optional(CONF_SHUNT_VOLTAGE): VOLTAGE_SCHEMA,
cv.Optional(CONF_CURRENT): CURRENT_SCHEMA,
cv.Optional(CONF_POWER): POWER_SCHEMA,
cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All(
cv.resistance, cv.Range(min=0.0, max=32.0)
),
cv.Optional(CONF_WARNING_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA,
cv.Optional(CONF_CRITICAL_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA,
}
),
cv.has_at_least_one_key(
CONF_BUS_VOLTAGE,
CONF_SHUNT_VOLTAGE,
CONF_CURRENT,
CONF_POWER,
CONF_WARNING_CURRENT_LIMIT,
CONF_CRITICAL_CURRENT_LIMIT,
),
validate_alert_limits,
)
CONFIG_SCHEMA = (
def adc_times_us(config: ConfigType) -> tuple[int, int]:
"""Return the bus and shunt conversion times in microseconds."""
adc_time = config[CONF_ADC_TIME]
if isinstance(adc_time, dict):
return adc_time[CONF_BUS_VOLTAGE], adc_time[CONF_SHUNT_VOLTAGE]
return adc_time, adc_time
def single_shot_wait_ms(config: ConfigType) -> int:
"""Time one conversion of every enabled channel takes, plus headroom."""
bus_us, shunt_us = adc_times_us(config)
channels = sum(1 for channel in CHANNELS if channel in config)
total_us = (bus_us + shunt_us) * config[CONF_ADC_AVERAGING] * channels
return -(-total_us // 1000) + SINGLE_SHOT_MARGIN_MS
def validate_single_shot_interval(config: ConfigType) -> ConfigType:
if config[CONF_CONTINUOUS_MODE]:
return config
wait_ms = single_shot_wait_ms(config)
if config[CONF_UPDATE_INTERVAL].total_milliseconds <= wait_ms:
raise cv.Invalid(
f"A single-shot conversion of all channels takes {wait_ms} ms, which does "
f"not fit in the update_interval; lower {CONF_ADC_AVERAGING} or "
f"{CONF_ADC_TIME}, or raise the interval",
path=[CONF_UPDATE_INTERVAL],
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(INA3221Component),
cv.Optional(CONF_CHANNEL_1): INA3221_CHANNEL_SCHEMA,
cv.Optional(CONF_CHANNEL_2): INA3221_CHANNEL_SCHEMA,
cv.Optional(CONF_CHANNEL_3): INA3221_CHANNEL_SCHEMA,
cv.Optional(CONF_CONTINUOUS_MODE, default=True): cv.boolean,
cv.Optional(CONF_ADC_AVERAGING, default=1): cv.one_of(
*ADC_AVERAGING, int=True
),
cv.Optional(CONF_ADC_TIME, default="8244us"): cv.Any(
validate_adc_time,
cv.Schema(
{
cv.Required(CONF_BUS_VOLTAGE): validate_adc_time,
cv.Required(CONF_SHUNT_VOLTAGE): validate_adc_time,
}
),
),
cv.Optional(CONF_SUM_SHUNT_VOLTAGE): VOLTAGE_SCHEMA,
cv.Optional(CONF_SUM_CURRENT): CURRENT_SCHEMA,
cv.Optional(CONF_SUM_POWER): POWER_SCHEMA,
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x40))
.extend(i2c.i2c_device_schema(0x40)),
cv.has_at_least_one_key(*CHANNELS),
validate_single_shot_interval,
)
def alert_limit_register(current_a: float, shunt_ohm: float) -> int:
return round(current_a * shunt_ohm / ALERT_LIMIT_LSB_V) << 3
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
for i, channel in enumerate([CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3]):
if channel not in config:
bus_us, shunt_us = adc_times_us(config)
register = (
ADC_AVERAGING[config[CONF_ADC_AVERAGING]] << ADC_AVERAGING_SHIFT
| ADC_TIMES_US[bus_us] << ADC_TIME_BUS_SHIFT
| ADC_TIMES_US[shunt_us] << ADC_TIME_SHUNT_SHIFT
| (MODE_CONTINUOUS if config[CONF_CONTINUOUS_MODE] else MODE_SINGLE_SHOT)
)
for i, channel in enumerate(CHANNELS):
if (conf := config.get(channel)) is None:
continue
conf = config[channel]
if CONF_SHUNT_RESISTANCE in conf:
cg.add(var.set_shunt_resistance(i, conf[CONF_SHUNT_RESISTANCE]))
register |= CHANNEL_ENABLE_BITS[i]
shunt = conf[CONF_SHUNT_RESISTANCE]
cg.add(var.set_shunt_resistance(i, shunt))
if CONF_BUS_VOLTAGE in conf:
sens = await sensor.new_sensor(conf[CONF_BUS_VOLTAGE])
cg.add(var.set_bus_voltage_sensor(i, sens))
@@ -98,3 +228,23 @@ async def to_code(config: ConfigType) -> None:
if CONF_POWER in conf:
sens = await sensor.new_sensor(conf[CONF_POWER])
cg.add(var.set_power_sensor(i, sens))
for key, setter in (
(CONF_WARNING_CURRENT_LIMIT, var.set_warning_limit),
(CONF_CRITICAL_CURRENT_LIMIT, var.set_critical_limit),
):
if (limit := conf.get(key)) is not None:
cg.add_define("USE_INA3221_ALERT_LIMITS")
cg.add(setter(i, alert_limit_register(limit, shunt)))
cg.add(var.set_config_register(register))
if not config[CONF_CONTINUOUS_MODE]:
cg.add(var.set_single_shot_wait_ms(single_shot_wait_ms(config)))
if any(
key in config
for key in (CONF_SUM_SHUNT_VOLTAGE, CONF_SUM_CURRENT, CONF_SUM_POWER)
):
cg.add_define("USE_INA3221_SUMMATION")
sensors = sensor.sub_sensors(config)
await sensors(CONF_SUM_SHUNT_VOLTAGE, var.set_sum_shunt_voltage_sensor)
await sensors(CONF_SUM_CURRENT, var.set_sum_current_sensor)
await sensors(CONF_SUM_POWER, var.set_sum_power_sensor)
+2
View File
@@ -4,6 +4,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
AUTO_LOAD = ["ld24xx"]
CODEOWNERS = ["@NovakIrs"]
DEPENDENCIES = ["uart"]
DOMAIN = "ld2410s"
@@ -13,6 +14,7 @@ CONF_LD2410S_ID = "ld2410s_id"
ld2410s_ns = cg.esphome_ns.namespace(DOMAIN)
LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice)
Parameter = ld2410s_ns.enum("Parameter", is_class=True)
CONFIG_SCHEMA = (
cv.Schema(
+264 -45
View File
@@ -1,4 +1,5 @@
#include "ld2410s.h"
#include "ld2410s_number.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/application.h"
@@ -25,18 +26,46 @@ static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word
static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF;
static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001;
static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE;
static constexpr uint16_t VERSION_READ_CMD = 0x0000;
static constexpr uint16_t PARAMETERS_WRITE_CMD = 0x0070;
static constexpr uint16_t PARAMETERS_READ_CMD = 0x0071;
static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A;
// Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes
static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6;
static constexpr uint8_t CMD_FRAME_MAX_SIZE =
LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER);
// The parameter write carries a 16-bit key and a 32-bit value per parameter, the read only the keys
static constexpr uint8_t PARAMETER_KEY_SIZE = 2;
static constexpr uint8_t PARAMETER_VALUE_SIZE = 4;
static constexpr uint8_t PARAMETERS_WRITE_SIZE = PARAMETER_COUNT * (PARAMETER_KEY_SIZE + PARAMETER_VALUE_SIZE);
static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_WRITE_SIZE + sizeof(CMD_FRAME_FOOTER);
// Keys of the parameters, in Parameter order
#ifdef USE_LD2410S_PARAMETERS
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
#endif
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
static constexpr uint8_t VERSION_SIZE = VERSION_TYPE_SIZE + 3 * 2;
// Everything the module needs at start: enter configuration mode, pick the output mode, leave it
static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD,
CONFIG_MODE_END_CMD};
// Everything the module needs at start: configuration mode on, read the version and parameters when
// an entity shows them, pick the output mode, configuration mode off
static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {
CONFIG_MODE_START_CMD,
#ifdef USE_LD2410S_VERSION
VERSION_READ_CMD,
#endif
#ifdef USE_LD2410S_PARAMETERS
PARAMETERS_READ_CMD,
#endif
OUTPUT_MODE_SWITCH_CMD, CONFIG_MODE_END_CMD,
};
#ifdef USE_LD2410S_PARAMETERS
static constexpr uint16_t WRITE_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, PARAMETERS_WRITE_CMD, CONFIG_MODE_END_CMD};
#endif
static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]);
static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); }
static constexpr uint8_t INIT_MAX_TIMEOUTS = 2;
#ifdef USE_LD2410S_PARAMETERS
static constexpr uint8_t WRITE_SEQUENCE_LENGTH = sizeof(WRITE_SEQUENCE) / sizeof(WRITE_SEQUENCE[0]);
#endif
static constexpr uint8_t STEP_MAX_TIMEOUTS = 2;
static constexpr uint32_t ACK_TIMEOUT_MS = 300;
static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command
static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering
@@ -49,6 +78,20 @@ void LD2410SComponent::dump_config() {
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_);
#endif
#ifdef USE_SENSOR
LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Distance", this->distance_sensor_);
#endif
#ifdef USE_LD2410S_VERSION
LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_);
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
for (auto *number : this->numbers_) {
LOG_NUMBER(" ", "Setting", number);
}
#endif
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
LOG_SELECT(" ", "Response Speed", this->response_speed_select_);
#endif
}
void LD2410SComponent::loop() {
@@ -67,35 +110,80 @@ void LD2410SComponent::loop() {
avail -= len;
}
const uint32_t now = App.get_loop_component_start_time();
this->run_init_sequence_(now);
if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) {
if (this->sequence_ == nullptr) {
if (!this->init_done_) {
this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, now);
#ifdef USE_LD2410S_PARAMETERS
} else if (this->parameters_dirty_) {
this->parameters_dirty_ = false;
this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_);
#endif
}
}
this->run_sequence_(now);
if (this->init_done_ && now - this->last_frame_at_ > LINK_TIMEOUT_MS) {
// The count tells real silence apart from frames that never parse (wrong baud rate or firmware)
ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000,
this->dropped_frames_);
this->dropped_frames_ = 0;
this->status_set_warning();
this->init_step_ = 0;
this->init_timeouts_ = 0;
this->awaiting_ack_ = false;
this->next_send_at_ = now;
this->restart_init_(now);
}
}
void LD2410SComponent::run_init_sequence_(uint32_t now) {
if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast<int32_t>(now - this->next_send_at_) < 0) {
#ifdef USE_LD2410S_PARAMETERS
bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
// All six are written together, so the other five must be the module's own values
if (!this->init_done_) {
ESP_LOGW(TAG, "Parameters not read yet, change ignored");
return false;
}
if (!this->parameters_known_) {
ESP_LOGW(TAG, "The module reported a parameter out of range, changes are disabled");
return false;
}
this->parameters_[static_cast<uint8_t>(parameter)] = value;
this->parameters_dirty_ = true; // written by loop() once no sequence is running
return true;
}
#endif
void LD2410SComponent::restart_init_(uint32_t at) {
this->init_done_ = false;
this->status_set_warning(); // cleared again when the new init sequence is acknowledged
#ifdef USE_LD2410S_PARAMETERS
// The module's own values count again once it is set up anew
if (this->parameters_dirty_) {
ESP_LOGW(TAG, "Dropping the pending parameter change, the module's values are read back");
}
this->parameters_dirty_ = false;
this->parameters_known_ = false; // a refused value may sit in parameters_ until the read replaces it
#endif
this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, at);
}
void LD2410SComponent::start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now) {
this->sequence_ = sequence;
this->sequence_len_ = length;
this->step_ = 0;
this->step_timeouts_ = 0;
this->awaiting_ack_ = false;
this->next_send_at_ = now;
}
uint16_t LD2410SComponent::current_command_() const { return progmem_read_uint16(&this->sequence_[this->step_]); }
void LD2410SComponent::run_sequence_(uint32_t now) {
if (this->sequence_ == nullptr || static_cast<int32_t>(now - this->next_send_at_) < 0) {
return;
}
const uint16_t command = init_command(this->init_step_);
const uint16_t command = this->current_command_();
if (this->awaiting_ack_) {
if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) {
if (++this->step_timeouts_ > STEP_MAX_TIMEOUTS) {
ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command,
REINIT_PAUSE_MS / 1000);
// Leave configuration mode whatever state the module is in, then begin again
this->send_command_(CONFIG_MODE_END_CMD);
this->init_step_ = 0;
this->init_timeouts_ = 0;
this->awaiting_ack_ = false;
this->next_send_at_ = now + REINIT_PAUSE_MS;
this->restart_init_(now + REINIT_PAUSE_MS);
return;
}
ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command);
@@ -120,6 +208,22 @@ void LD2410SComponent::send_command_(uint16_t command) {
frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF;
frame[pos++] = CONFIG_MODE_START_VALUE >> 8;
break;
#ifdef USE_LD2410S_PARAMETERS
case PARAMETERS_READ_CMD:
case PARAMETERS_WRITE_CMD:
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
const uint16_t key = progmem_read_uint16(&PARAMETER_KEYS[i]);
frame[pos++] = key & 0xFF;
frame[pos++] = key >> 8;
if (command == PARAMETERS_WRITE_CMD) {
const uint32_t value = this->parameters_[i]; // a 32-bit field on the wire
for (uint8_t shift = 0; shift < 32; shift += 8) {
frame[pos++] = (value >> shift) & 0xFF;
}
}
}
break;
#endif
default:
break;
}
@@ -252,27 +356,27 @@ bool LD2410SComponent::handle_frame_() {
}
bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) {
uint8_t state;
if (this->frame_type_ == FrameType::SHORT_DATA) {
// [state][distance low][distance high]
state = payload[0];
} else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) {
// [type][state][distance low][distance high]...; the module only sends these before the init
// sequence switches it to minimal output
state = payload[1];
} else {
if (len >= 1) {
ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]);
// Short frames carry [state][distance low][distance high]; standard frames put a type byte first, and the
// module only sends them before the init sequence switches it to minimal output
const uint8_t *data = payload;
if (this->frame_type_ == FrameType::STD_DATA) {
if (len < 4 || payload[0] != DATA_TYPE_STANDARD) {
if (len >= 1) {
ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]);
}
return true;
}
return true;
data = payload + 1;
}
const uint8_t state = data[0];
const uint16_t distance = encode_uint16(data[2], data[1]);
// States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to
// carry the header and footer bytes
if (state > 3) {
ESP_LOGV(TAG, "Dropping frame with impossible state %u", state);
return false;
}
this->publish_target_(state > 1);
this->publish_target_(state > 1, distance);
return true;
}
@@ -283,32 +387,147 @@ void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len)
}
const uint16_t command_word = encode_uint16(payload[1], payload[0]);
const uint16_t ack = encode_uint16(payload[3], payload[2]);
if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) {
if (!this->awaiting_ack_) {
ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word);
return;
}
if (ack != 0) {
// Leave the command pending so the timeout resends it
ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack);
const uint16_t command = this->current_command_();
if (command_word != (command | CMD_CONFIRMATION)) {
ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word);
return;
}
#ifdef USE_LD2410S_PARAMETERS
if (ack != 0 && command == PARAMETERS_WRITE_CMD) {
// Resending the same values would be rejected again; leave configuration mode and read the
// module's own values back, which republishes them
ESP_LOGW(TAG, "Module rejected the parameters, ack %04X", ack);
this->send_command_(CONFIG_MODE_END_CMD);
this->restart_init_(App.get_loop_component_start_time() + COMMAND_GAP_MS);
return;
}
#endif
if (ack != 0) {
// Leave the command pending so the timeout resends it
ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", command, ack);
return;
}
// A short answer leaves the read pending the same way, so init cannot finish without the values
#ifdef USE_LD2410S_PARAMETERS
if (command == PARAMETERS_READ_CMD && !this->handle_parameters_(&payload[4], len - 4))
return;
#endif
#ifdef USE_LD2410S_VERSION
if (command == VERSION_READ_CMD && !this->handle_version_(&payload[4], len - 4))
return;
#endif
this->awaiting_ack_ = false;
this->init_timeouts_ = 0;
this->init_step_++;
this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS;
if (this->init_step_ >= INIT_SEQUENCE_LENGTH) {
this->step_timeouts_ = 0;
const uint32_t now = App.get_loop_component_start_time();
this->next_send_at_ = now + COMMAND_GAP_MS;
if (++this->step_ < this->sequence_len_) {
return;
}
const bool was_init = this->sequence_ == INIT_SEQUENCE;
this->sequence_ = nullptr;
if (was_init) {
ESP_LOGD(TAG, "Setup done");
this->last_frame_at_ = App.get_loop_component_start_time();
this->init_done_ = true;
this->last_frame_at_ = now;
this->status_clear_warning();
}
}
void LD2410SComponent::publish_target_(bool target) {
#ifdef USE_LD2410S_PARAMETERS
// The answer lists the parameter values in key order, 32 bits each; returns false when it is cut short
bool LD2410SComponent::handle_parameters_(const uint8_t *values, uint16_t len) {
if (len < PARAMETER_COUNT * PARAMETER_VALUE_SIZE) {
ESP_LOGW(TAG, "Parameter answer too short (%u bytes)", len);
return false;
}
// Every parameter must fit the low byte of its little-endian value; otherwise nothing is stored or
// published and writes stay refused, so a cut-down value is never shown or sent back
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
const uint8_t *v = &values[i * PARAMETER_VALUE_SIZE];
if ((v[1] | v[2] | v[3]) != 0) {
ESP_LOGW(TAG, "Parameter %u out of range: %" PRIu32 ", changes stay disabled", i,
encode_uint32(v[3], v[2], v[1], v[0]));
this->parameters_known_ = false;
#ifdef USE_NUMBER
// A value a number showed before the module answered is not confirmed
for (auto *number : this->numbers_) {
if (number != nullptr) {
number->publish_state(NAN);
}
}
#endif
return true;
}
}
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
this->parameters_[i] = values[i * PARAMETER_VALUE_SIZE];
}
this->parameters_known_ = true;
this->publish_parameters_();
ESP_LOGD(TAG, "Parameters: gates %u to %u, no delay %u s, frequencies %u/%u dHz, response speed %u",
this->parameters_[1], this->parameters_[0], this->parameters_[2], this->parameters_[3], this->parameters_[4],
this->parameters_[5]);
return true;
}
#endif
#ifdef USE_LD2410S_VERSION
// Returns false when the answer is cut short
bool LD2410SComponent::handle_version_(const uint8_t *values, uint16_t len) {
if (len < VERSION_SIZE) {
ESP_LOGW(TAG, "Version answer too short (%u bytes)", len);
return false;
}
const uint8_t *v = &values[VERSION_TYPE_SIZE];
char version[18];
snprintf(version, sizeof(version), "%u.%u.%u", encode_uint16(v[1], v[0]), encode_uint16(v[3], v[2]),
encode_uint16(v[5], v[4]));
ESP_LOGD(TAG, "Firmware %s", version);
if (this->version_text_sensor_ != nullptr) {
this->version_text_sensor_->publish_state(version);
}
return true;
}
#endif
void LD2410SComponent::publish_target_(bool target, uint16_t distance) {
#ifdef USE_BINARY_SENSOR
if (this->target_binary_sensor_ != nullptr) {
this->target_binary_sensor_->publish_state(target);
}
#endif
#ifdef USE_SENSOR
// The module keeps reporting the last distance after the target left
SAFE_PUBLISH_SENSOR(this->distance_sensor_, target ? distance : 0);
#endif
}
#ifdef USE_LD2410S_PARAMETERS
void LD2410SComponent::publish_parameters_() {
#ifdef USE_NUMBER
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != nullptr) {
this->numbers_[i]->publish_raw(this->parameters_[i]);
}
}
#endif
#ifdef USE_SELECT
if (this->response_speed_select_ != nullptr) {
const uint8_t speed = this->parameters_[static_cast<uint8_t>(Parameter::PARAMETER_RESPONSE_SPEED)];
for (uint8_t i = 0; i < RESPONSE_SPEED_COUNT; i++) {
if (progmem_read_byte(&RESPONSE_SPEEDS[i]) == speed) {
this->response_speed_select_->publish_state(i);
return;
}
}
ESP_LOGV(TAG, "Unknown response speed %u", speed);
}
#endif
}
#endif
} // namespace esphome::ld2410s
+80 -4
View File
@@ -2,10 +2,21 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/components/uart/uart.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/ld24xx/ld24xx.h"
#endif
#ifdef USE_LD2410S_VERSION
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/select/select.h"
#endif
namespace esphome::ld2410s {
@@ -13,16 +24,55 @@ namespace esphome::ld2410s {
static constexpr size_t RX_BUFFER_SIZE = 96;
static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte");
/// The module's common parameters, in the order the parameter read answers them
enum class Parameter : uint8_t {
PARAMETER_MAX_DISTANCE,
PARAMETER_MIN_DISTANCE,
PARAMETER_NO_DELAY,
PARAMETER_STATUS_FREQUENCY,
PARAMETER_DISTANCE_FREQUENCY,
PARAMETER_RESPONSE_SPEED,
PARAMETER_COUNT,
};
static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAMETER_COUNT);
// Response speed values, in the order of the select's options (Normal, Fast)
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
class LD2410SNumber;
#endif
class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(target)
#endif
#ifdef USE_SENSOR
SUB_SENSOR_WITH_DEDUP(distance, uint16_t)
#endif
#ifdef USE_LD2410S_VERSION
SUB_TEXT_SENSOR(version)
#endif
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
SUB_SELECT(response_speed)
#endif
public:
void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence
void loop() override;
void dump_config() override;
/// Store a parameter in the module's units; loop() then writes all of them to the module.
/// Returns false, and changes nothing, until the module's own values have been read.
#ifdef USE_LD2410S_PARAMETERS
bool set_parameter(Parameter parameter, uint8_t value);
#ifdef USE_NUMBER
void set_number(Parameter parameter, LD2410SNumber *number) {
this->numbers_[static_cast<uint8_t>(parameter)] = number;
}
#endif
#endif
protected:
enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND };
@@ -32,6 +82,12 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
/// Returns false for a data frame whose state byte is impossible, so it counts as dropped.
bool handle_data_frame_(const uint8_t *payload, uint16_t len);
void handle_command_ack_(const uint8_t *payload, uint16_t len);
#ifdef USE_LD2410S_PARAMETERS
bool handle_parameters_(const uint8_t *values, uint16_t len);
#endif
#ifdef USE_LD2410S_VERSION
bool handle_version_(const uint8_t *values, uint16_t len);
#endif
void count_dropped_frame_() {
if (this->dropped_frames_ < UINT8_MAX) {
this->dropped_frames_++;
@@ -42,21 +98,41 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
this->expected_len_ = 0;
this->frame_type_ = FrameType::NONE;
}
void run_init_sequence_(uint32_t now);
void start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now);
void restart_init_(uint32_t at);
void run_sequence_(uint32_t now);
uint16_t current_command_() const;
void send_command_(uint16_t command);
void publish_target_(bool target);
void publish_target_(bool target, uint16_t distance);
#ifdef USE_LD2410S_PARAMETERS
void publish_parameters_();
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
#endif
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
uint32_t next_send_at_{0};
uint32_t last_frame_at_{0};
#ifdef USE_LD2410S_PARAMETERS
uint8_t parameters_[PARAMETER_COUNT]{}; // all fit a byte: gates, seconds, tenths of a hertz, speed
#endif
uint8_t rx_buffer_[RX_BUFFER_SIZE];
uint8_t rx_len_{0};
uint8_t expected_len_{0};
uint8_t init_step_{0};
uint8_t init_timeouts_{0};
uint8_t sequence_len_{0};
uint8_t step_{0};
uint8_t step_timeouts_{0};
uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255
FrameType frame_type_{FrameType::NONE};
bool awaiting_ack_{false};
bool rescanning_{false};
bool init_done_{false};
#ifdef USE_LD2410S_PARAMETERS
bool parameters_known_{false}; // the module answered the parameter read at least once
bool parameters_dirty_{false};
#endif
};
} // namespace esphome::ld2410s
@@ -0,0 +1,32 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/number/number.h"
namespace esphome::ld2410s {
/// Edits one common parameter; scale converts the module's units to the number's.
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
public:
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
protected:
void control(float value) override {
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
if (this->parent_->set_parameter(this->parameter_, raw))
this->publish_raw(raw);
}
const Parameter parameter_;
const float scale_;
};
} // namespace esphome::ld2410s
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/select/select.h"
namespace esphome::ld2410s {
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
protected:
void control(size_t index) override {
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
this->publish_state(index);
}
};
} // namespace esphome::ld2410s
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
+108
View File
@@ -0,0 +1,108 @@
from functools import partial
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_FREQUENCY,
ENTITY_CATEGORY_CONFIG,
ICON_PULSE,
ICON_TIMELAPSE,
UNIT_HERTZ,
UNIT_METER,
UNIT_SECOND,
)
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
DEPENDENCIES = ["ld2410s"]
CONF_DISTANCE_REPORTING_FREQUENCY = "distance_reporting_frequency"
CONF_MAX_DISTANCE = "max_distance"
CONF_MIN_DISTANCE = "min_distance"
CONF_NO_DELAY = "no_delay"
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
GATE_WIDTH_M = 0.7
FREQUENCY_SCALE = 0.1
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
_FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
return number.number_schema(
LD2410SNumber,
device_class=device_class,
entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=unit,
icon=icon,
)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Optional(CONF_MAX_DISTANCE): _number_schema(
DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-right"
),
cv.Optional(CONF_MIN_DISTANCE): _number_schema(
DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-left"
),
cv.Optional(CONF_NO_DELAY): _number_schema(
DEVICE_CLASS_DURATION, UNIT_SECOND, ICON_TIMELAPSE
),
cv.Optional(CONF_STATUS_REPORTING_FREQUENCY): _number_schema(
DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE
),
cv.Optional(CONF_DISTANCE_REPORTING_FREQUENCY): _number_schema(
DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE
),
}
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LD2410S_PARAMETERS")
hub = await cg.get_variable(config[CONF_LD2410S_ID])
numbers = number.sub_numbers(config, parent=hub)
await numbers(
CONF_MAX_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
Parameter.PARAMETER_MAX_DISTANCE,
GATE_WIDTH_M,
**_DISTANCE_RANGE,
)
await numbers(
CONF_MIN_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
Parameter.PARAMETER_MIN_DISTANCE,
GATE_WIDTH_M,
**_DISTANCE_RANGE,
)
await numbers(
CONF_NO_DELAY,
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
Parameter.PARAMETER_NO_DELAY,
1,
min_value=1,
max_value=120,
step=1,
)
await numbers(
CONF_STATUS_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
Parameter.PARAMETER_STATUS_FREQUENCY,
FREQUENCY_SCALE,
**_FREQUENCY_RANGE,
)
await numbers(
CONF_DISTANCE_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
Parameter.PARAMETER_DISTANCE_FREQUENCY,
FREQUENCY_SCALE,
**_FREQUENCY_RANGE,
)
+31
View File
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
DEPENDENCIES = ["ld2410s"]
CONF_RESPONSE_SPEED = "response_speed"
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
LD2410SSelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:speedometer",
),
}
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LD2410S_PARAMETERS")
hub = await cg.get_variable(config[CONF_LD2410S_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
)
+32
View File
@@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_DISTANCE,
DEVICE_CLASS_DISTANCE,
ICON_SIGNAL_DISTANCE_VARIANT,
STATE_CLASS_MEASUREMENT,
UNIT_CENTIMETER,
)
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent
DEPENDENCIES = ["ld2410s"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Required(CONF_DISTANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_CENTIMETER,
icon=ICON_SIGNAL_DISTANCE_VARIANT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DISTANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
}
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2410S_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISTANCE, hub.set_distance_sensor)
+23
View File
@@ -0,0 +1,23 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent
DEPENDENCIES = ["ld2410s"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Required(CONF_VERSION): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP
),
}
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_LD2410S_VERSION")
hub = await cg.get_variable(config[CONF_LD2410S_ID])
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
+13 -7
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include "esphome/core/finite_set_mask.h"
#include "esphome/core/hal.h"
namespace esphome::light {
@@ -108,8 +109,9 @@ constexpr ColorModeHelper operator|(ColorModeHelper lhs, ColorMode rhs) {
using color_mode_bitmask_t = uint16_t;
// Lookup table for ColorMode bit mapping
// This array defines the canonical order of color modes (bit 0-9)
constexpr ColorMode COLOR_MODE_LOOKUP[] = {
// This array defines the canonical order of color modes (bit 0-9). One shared copy in flash; read it at run time
// through color_mode_at().
inline constexpr ColorMode COLOR_MODE_LOOKUP[] PROGMEM = {
ColorMode::UNKNOWN, // bit 0
ColorMode::ON_OFF, // bit 1
ColorMode::BRIGHTNESS, // bit 2
@@ -122,6 +124,12 @@ constexpr ColorMode COLOR_MODE_LOOKUP[] = {
ColorMode::RGB_COLD_WARM_WHITE, // bit 9
};
constexpr ColorMode color_mode_at(unsigned bit) {
if (__builtin_is_constant_evaluated())
return COLOR_MODE_LOOKUP[bit];
return static_cast<ColorMode>(progmem_read_byte(reinterpret_cast<const uint8_t *>(&COLOR_MODE_LOOKUP[bit])));
}
/// Bit mapping policy for ColorMode
/// Uses lookup table for non-contiguous enum values
struct ColorModeBitPolicy {
@@ -129,17 +137,15 @@ struct ColorModeBitPolicy {
static constexpr int MAX_BITS = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]);
static constexpr unsigned to_bit(ColorMode mode) {
// Linear search through lookup table
// Compiler optimizes this to efficient code since array is constexpr
for (int i = 0; i < MAX_BITS; ++i) {
if (COLOR_MODE_LOOKUP[i] == mode)
if (color_mode_at(i) == mode)
return i;
}
return 0;
}
static constexpr ColorMode from_bit(unsigned bit) {
return (bit < MAX_BITS) ? COLOR_MODE_LOOKUP[bit] : ColorMode::UNKNOWN;
return (bit < MAX_BITS) ? color_mode_at(bit) : ColorMode::UNKNOWN;
}
};
@@ -157,7 +163,7 @@ constexpr uint16_t compute_capability_bitmask(ColorCapability capability) {
// Check each ColorMode to see if it has this capability
constexpr int color_mode_count = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]);
for (int bit = 0; bit < color_mode_count; ++bit) {
uint8_t mode_val = static_cast<uint8_t>(COLOR_MODE_LOOKUP[bit]);
uint8_t mode_val = static_cast<uint8_t>(color_mode_at(bit));
if ((mode_val & cap_bit) != 0) {
mask |= (1 << bit);
}
@@ -29,4 +29,7 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
}
}
uint8_t *ImageDecoder::image_buffer_() const { return this->image_->buffer_; }
bool ImageDecoder::image_is_big_endian_() const { return this->image_->is_big_endian_; }
} // namespace esphome::runtime_image
@@ -127,6 +127,10 @@ class ImageDecoder {
}
protected:
/// The image's pixel buffer, for decoders that write their output straight into it
uint8_t *image_buffer_() const;
bool image_is_big_endian_() const;
double x_scale_ = 1.0;
double y_scale_ = 1.0;
RuntimeImage *image_;
@@ -2,6 +2,7 @@
#ifdef USE_RUNTIME_IMAGE_JPEG
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/image/image.h"
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
@@ -15,6 +16,67 @@ ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg");
namespace esphome::runtime_image {
bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
if (this->x_boundaries_.capacity() < static_cast<size_t>(width + 1) && !this->x_boundaries_.try_init(width + 1)) {
return false;
}
if (this->y_boundaries_.capacity() < static_cast<size_t>(height + 1) && !this->y_boundaries_.try_init(height + 1)) {
return false;
}
this->source_width_ = width;
this->source_height_ = height;
this->x_boundaries_.clear();
this->y_boundaries_.clear();
const uint32_t target_width = this->image_->get_buffer_width();
const uint32_t target_height = this->image_->get_buffer_height();
// Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6;
// the generic draw() path rounds down and keeps the odd ones instead
for (uint32_t x = 0; x <= static_cast<uint32_t>(width); x++) {
this->x_boundaries_.push_back(static_cast<uint16_t>((x * target_width + width - 1) / width));
}
for (uint32_t y = 0; y <= static_cast<uint32_t>(height); y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>((y * target_height + height - 1) / height));
}
return true;
}
void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) {
if (pixels == nullptr) {
return;
}
uint8_t *const buffer = this->image_buffer_();
const uint32_t buffer_width = this->image_->get_buffer_width();
// JPEGDEC gives little-endian words; a big-endian image stores the high byte first
const uint8_t first = this->image_is_big_endian_() ? 1 : 0;
for (int source_y = 0; source_y < h; source_y++) {
const int absolute_y = y + source_y;
if (absolute_y < 0 || absolute_y >= this->source_height_) {
continue;
}
const uint16_t y1 = this->y_boundaries_[absolute_y];
const uint16_t y2 = this->y_boundaries_[absolute_y + 1];
for (int source_x = 0; source_x < w; source_x++) {
const int absolute_x = x + source_x;
if (absolute_x < 0 || absolute_x >= this->source_width_) {
continue;
}
const uint16_t x1 = this->x_boundaries_[absolute_x];
const uint16_t x2 = this->x_boundaries_[absolute_x + 1];
const uint8_t *pixel = pixels + (source_y * w + source_x) * 2;
for (uint16_t destination_y = y1; destination_y < y2; destination_y++) {
uint8_t *destination = buffer + (destination_y * buffer_width + x1) * 2;
for (uint16_t destination_x = x1; destination_x < x2; destination_x++) {
destination[0] = pixel[first];
destination[1] = pixel[1 - first];
destination += 2;
}
}
}
}
}
/**
* @brief Callback method that will be called by the JPEGDEC engine when a chunk
* of the image is decoded.
@@ -22,7 +84,12 @@ namespace esphome::runtime_image {
* @param jpeg The JPEGDRAW object, including the context data.
*/
static int draw_callback(JPEGDRAW *jpeg) {
ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser;
JpegDecoder *decoder = (JpegDecoder *) jpeg->pUser;
if (decoder == nullptr) {
ESP_LOGE(TAG, "Decoder pointer is null!");
return 0;
}
// Some very big images take too long to decode, so feed the watchdog on each callback
// to avoid crashing if the executing task has a watchdog enabled.
@@ -33,6 +100,11 @@ static int draw_callback(JPEGDRAW *jpeg) {
#ifdef USE_ESP_IDF
}
#endif
if (decoder->uses_direct_rgb565()) {
decoder->draw_rgb565(jpeg->x, jpeg->y, jpeg->iWidth, jpeg->iHeight,
reinterpret_cast<const uint8_t *>(jpeg->pPixels));
return 1;
}
size_t position = 0;
size_t height = static_cast<size_t>(jpeg->iHeight);
size_t width = static_cast<size_t>(jpeg->iWidth);
@@ -42,10 +114,6 @@ static int draw_callback(JPEGDRAW *jpeg) {
auto ba = decode_value(jpeg->pPixels[position++]);
Color color(rg[1], rg[0], ba[1], ba[0]);
if (!decoder) {
ESP_LOGE(TAG, "Decoder pointer is null!");
return 0;
}
decoder->draw(jpeg->x + x, jpeg->y + y, 1, 1, color);
}
}
@@ -78,10 +146,20 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
this->jpeg_.setUserPointer(this);
this->jpeg_.setPixelType(RGB8888);
this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency();
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_.setPixelType(RGB8888);
}
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (!this->jpeg_.decode(0, 0, 0)) {
auto error = this->jpeg_.getLastError();
ESP_LOGE(TAG, "Error while decoding: %d", error);
@@ -1,8 +1,8 @@
#pragma once
#include "image_decoder.h"
#include "runtime_image.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_RUNTIME_IMAGE_JPEG
#include <JPEGDEC.h>
@@ -23,8 +23,18 @@ class JpegDecoder : public ImageDecoder {
int HOT decode(uint8_t *buffer, size_t size) override;
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
protected:
bool prepare_rgb565_scaling_(int width, int height);
JPEGDEC jpeg_{};
FixedVector<uint16_t> x_boundaries_{};
FixedVector<uint16_t> y_boundaries_{};
int source_width_{0};
int source_height_{0};
bool direct_rgb565_{false};
};
} // namespace esphome::runtime_image
@@ -234,6 +234,8 @@ class RuntimeImage : public image::Image {
bool is_big_endian_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
friend class ImageDecoder;
};
} // namespace esphome::runtime_image
+369 -30
View File
@@ -1,54 +1,358 @@
#include "selec_meter.h"
#include "selec_meter_registers.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cmath>
#include <cstdio>
namespace esphome::selec_meter {
namespace helpers = modbus::helpers;
ESPHOME_LOG_TAG(TAG, "selec_meter");
static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers
static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers
// Durable refusals an optional read tolerates before it is given up on
static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5;
static const LogString *read_state_name(ReadState state) {
switch (state) {
case ReadState::READ_STATE_MAIN_BLOCK:
return LOG_STR("main block");
case ReadState::READ_STATE_SERIAL_NUMBER:
return LOG_STR("serial number");
case ReadState::READ_STATE_DG_SENSING:
return LOG_STR("DG sensing");
case ReadState::READ_STATE_IDLE:
default:
return LOG_STR("idle");
}
}
// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when
// `reg` is not wholly inside `registers`
static float read_float(std::span<const uint16_t> registers, uint16_t start_address, uint16_t reg, bool word_swapped) {
auto value = word_swapped ? helpers::value_at<helpers::SensorValueType::FP32_R>(registers, start_address, reg)
: helpers::value_at<helpers::SensorValueType::FP32>(registers, start_address, reg);
return value.value_or(NAN);
}
// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded
// phase's power factor; a non-finite field is skipped on its own so the others still publish.
static void publish_float(sensor::Sensor *sens, std::span<const uint16_t> registers, uint16_t reg, bool kilo,
bool word_swapped) {
float value = read_float(registers, 0, reg, word_swapped);
if (!std::isfinite(value)) {
ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str());
return;
}
sens->publish_state(kilo ? value * 1000.0f : value);
}
#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR))
// Counts one failure of an optional read; callers only run while `failures` is below the limit
static void note_optional_read_failure(uint8_t &failures, const LogString *name) {
if (++failures == MAX_OPTIONAL_READ_FAILURES) {
ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures);
}
}
#endif
SelecMeter::ReadRange SelecMeter::read_range_(ReadState state) const {
switch (state) {
case ReadState::READ_STATE_MAIN_BLOCK:
#ifdef USE_SELEC_METER_EM4M
if (this->model_ == Model::MODEL_EM4M)
return {0, EM4M_REGISTER_COUNT};
#endif
return {0, EM2M_REGISTER_COUNT};
case ReadState::READ_STATE_SERIAL_NUMBER:
return {EM4M_SERIAL_NUMBER, 2};
case ReadState::READ_STATE_DG_SENSING:
return {EM4M_DG_SENSING, 2};
case ReadState::READ_STATE_IDLE:
default:
return {0, 0};
}
}
bool SelecMeter::wanted_(ReadState state) const {
switch (state) {
case ReadState::READ_STATE_MAIN_BLOCK:
return true;
#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
case ReadState::READ_STATE_SERIAL_NUMBER:
return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES;
#endif
#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
case ReadState::READ_STATE_DG_SENSING:
return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES;
#endif
default:
return false;
}
}
ReadState SelecMeter::next_read_state_after_(ReadState current) const {
for (auto next = static_cast<uint8_t>(current) + 1; next < static_cast<uint8_t>(ReadState::READ_STATE_IDLE); next++) {
if (this->wanted_(static_cast<ReadState>(next)))
return static_cast<ReadState>(next);
}
return ReadState::READ_STATE_IDLE;
}
void SelecMeter::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) {
if (!modbus::succeeded(status))
return; // the hub already logs exception responses
// Publish a sensor if both of its registers are in this response; skipping absent registers keeps
// this correct for any read range, so the poll may be split into multiple requests.
// Values are 32-bit floats, low word first.
auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void {
if (sensor == nullptr)
return;
if (auto value = helpers::value_at<helpers::SensorValueType::FP32_R>(registers, start_address, reg))
sensor->publish_state(*value * unit);
ReadState current = this->read_state_;
if (current == ReadState::READ_STATE_IDLE) {
ESP_LOGW(TAG, "Unexpected response while idle, dropping");
return;
}
// start_address comes from the exact request this answers, so a stale reply cannot match another read
if (start_address != this->read_range_(current).start) {
this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false);
return;
}
if (status.has_value()) {
const auto code = *status;
ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast<uint8_t>(code),
LOG_STR_ARG(read_state_name(current)));
// Illegal function/address are durable; busy, acknowledge and the rest are transient
const bool durable =
code == modbus::ExceptionCode::ILLEGAL_FUNCTION || code == modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS;
this->fail_current_read_(nullptr, durable);
return;
}
switch (current) {
case ReadState::READ_STATE_MAIN_BLOCK: {
#ifdef USE_SELEC_METER_EM4M
bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers);
#else
bool ok = this->decode_em2m_(registers);
#endif
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning();
}
break;
}
#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
case ReadState::READ_STATE_SERIAL_NUMBER:
this->decode_serial_number_(registers);
break;
#endif
#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
case ReadState::READ_STATE_DG_SENSING:
this->decode_dg_sensing_(registers);
break;
#endif
default:
break;
}
// Safe per modbus.h's callback contract: sending from inside a callback is picked up normally.
this->start_read_(this->next_read_state_after_(current));
}
void SelecMeter::fail_current_read_(const LogString *reason, bool durable) {
if (this->read_state_ == ReadState::READ_STATE_IDLE) {
ESP_LOGW(TAG, "Unexpected failure while idle, dropping");
return;
}
ReadState failed_state = this->read_state_;
if (reason != nullptr) {
ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state)));
}
// Only the main block drives component health; an unsupported optional read is not a fault
if (failed_state == ReadState::READ_STATE_MAIN_BLOCK)
this->status_set_warning();
#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable)
note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state));
#endif
#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
if (failed_state == ReadState::READ_STATE_DG_SENSING && durable)
note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state));
#endif
this->start_read_(this->next_read_state_after_(failed_state));
}
bool SelecMeter::on_no_response(std::span<const uint8_t> /*request_pdu*/) {
this->fail_current_read_(LOG_STR("No Modbus response"));
return false;
}
void SelecMeter::on_not_sent(std::span<const uint8_t> /*request_pdu*/) {
this->fail_current_read_(LOG_STR("Modbus request not sent"));
}
#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
void SelecMeter::decode_serial_number_(std::span<const uint16_t> registers) {
auto serial =
this->word_swap_
? helpers::value_at<helpers::SensorValueType::U_DWORD_R>(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER)
: helpers::value_at<helpers::SensorValueType::U_DWORD>(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER);
if (!serial) {
ESP_LOGW(TAG, "Unexpected response size for serial number");
note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER));
return;
}
char buf[9];
snprintf(buf, sizeof(buf), "%08" PRIX32, *serial);
this->serial_number_sensor_->publish_state(buf);
// The serial number never changes, so stop reading it
this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES;
}
#endif
#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
void SelecMeter::decode_dg_sensing_(std::span<const uint16_t> registers) {
float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_);
if (!std::isfinite(value)) {
ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring");
note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING));
return;
}
this->dg_sensing_sensor_->publish_state(value != 0);
this->dg_sensing_failures_ = 0;
}
#endif
bool SelecMeter::publish_fields_(std::span<const uint16_t> registers, std::span<const FloatField> fields,
uint8_t min_count) {
if (registers.size() < min_count) {
ESP_LOGW(TAG, "Short response: %u registers", static_cast<unsigned>(registers.size()));
return false;
}
for (const auto &entry : fields) {
FloatField field;
progmem_memcpy(&field, &entry, sizeof(field));
sensor::Sensor *sens = this->*field.sensor;
if (sens != nullptr)
publish_float(sens, registers, field.reg, field.kilo, this->word_swap_);
}
return true;
}
bool SelecMeter::decode_em2m_(std::span<const uint16_t> registers) {
static constexpr FloatField EM2M_FIELDS[] PROGMEM = {
{&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false},
{&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false},
{&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false},
{&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false},
{&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false},
{&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false},
{&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false},
{&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true},
{&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true},
{&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true},
{&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false},
{&SelecMeter::current_sensor_, SELEC_CURRENT, false},
{&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false},
{&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false},
{&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true},
{&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true},
{&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true},
};
return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT);
}
#ifdef USE_SELEC_METER_EM4M
bool SelecMeter::decode_em4m_(std::span<const uint16_t> registers) {
static constexpr FloatField EM4M_FIELDS[] PROGMEM = {
// Common quantities, shared sensor keys with EM2M
{&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false},
{&SelecMeter::current_sensor_, EM4M_CURRENT, false},
{&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true},
{&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true},
{&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true},
{&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false},
{&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false},
{&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true},
{&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true},
{&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true},
{&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false},
{&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false},
{&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false},
{&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false},
// Line-to-line and net (mains/DG) quantities have no per-phase equivalent
{&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false},
{&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false},
{&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false},
{&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false},
{&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false},
{&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false},
{&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false},
{&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false},
{&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false},
{&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false},
};
// reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart
struct PhaseField {
sensor::Sensor *Phase::*sensor;
uint16_t reg_l1;
bool kilo;
};
static constexpr PhaseField PHASE_FIELDS[] PROGMEM = {
{&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false},
{&Phase::current_sensor_, EM4M_CURRENT_L1, false},
{&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true},
{&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true},
{&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true},
{&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false},
{&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false},
{&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false},
{&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false},
{&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false},
{&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false},
};
if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT))
return false;
for (const auto &entry : PHASE_FIELDS) {
PhaseField field;
progmem_memcpy(&field, &entry, sizeof(field));
for (uint8_t phase = 0; phase < 3; phase++) {
sensor::Sensor *sens = this->phases_[phase].*field.sensor;
if (sens != nullptr)
publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_);
}
}
return true;
}
#endif
void SelecMeter::update() {
if (this->read_state_ != ReadState::READ_STATE_IDLE) {
ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress",
LOG_STR_ARG(read_state_name(this->read_state_)));
return;
}
this->start_read_(ReadState::READ_STATE_MAIN_BLOCK);
}
publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT);
publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT);
publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT);
publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT);
publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT);
publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT);
publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT);
publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT);
void SelecMeter::start_read_(ReadState state) {
this->read_state_ = state;
if (state == ReadState::READ_STATE_IDLE)
return;
const ReadRange range = this->read_range_(state);
// A refused send gets no callback, so unwind here like the async failure paths
if (!this->read_input_registers(range.start, range.count))
this->fail_current_read_(LOG_STR("Modbus request refused"));
}
void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); }
void SelecMeter::dump_config() {
ESP_LOGCONFIG(TAG,
"SELEC Meter:\n"
" Model: %s\n"
" Byte Order: %s\n"
" Address: 0x%02X",
this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"),
this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"),
this->address_);
LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_);
LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_);
@@ -67,6 +371,41 @@ void SelecMeter::dump_config() {
LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_);
LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_);
LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_);
#ifdef USE_SELEC_METER_EM4M
if (this->model_ == Model::MODEL_EM4M) {
LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_);
LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_);
LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_);
LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_);
for (uint8_t phase = 0; phase < 3; phase++) {
const auto &p = this->phases_[phase];
ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase);
LOG_SENSOR(" ", "Voltage", p.voltage_sensor_);
LOG_SENSOR(" ", "Current", p.current_sensor_);
LOG_SENSOR(" ", "Active Power", p.active_power_sensor_);
LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_);
LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_);
LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_);
LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_);
LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_);
LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_);
LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_);
LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_);
}
LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_);
LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_);
LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_);
LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_);
LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_);
LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_);
}
#ifdef USE_TEXT_SENSOR
LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_);
#endif
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_);
#endif
#endif
}
} // namespace esphome::selec_meter
@@ -1,8 +1,15 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/modbus/modbus.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#include <span>
@@ -15,6 +22,24 @@ namespace esphome::selec_meter {
public: \
void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; }
// Per-phase sensor in the Phase struct, setter indexed by phase (0 = L1/A, 1 = L2/B, 2 = L3/C)
#define SELEC_METER_PHASE_SENSOR(name) \
void set_##name##_sensor(uint8_t phase, sensor::Sensor *(name)) { this->phases_[phase].name##_sensor_ = name; }
enum class Model : uint8_t {
MODEL_EM2M,
MODEL_EM4M,
};
// Read cycle steps in the order they run. The extra reads (serial number, DG sensing) sit far outside
// the main block and get their own transaction.
enum class ReadState : uint8_t {
READ_STATE_MAIN_BLOCK,
READ_STATE_SERIAL_NUMBER,
READ_STATE_DG_SENSING,
READ_STATE_IDLE,
};
class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice {
public:
SELEC_METER_SENSOR(total_active_energy)
@@ -35,12 +60,122 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev
SELEC_METER_SENSOR(maximum_demand_reactive_power)
SELEC_METER_SENSOR(maximum_demand_apparent_power)
#ifdef USE_SELEC_METER_EM4M
// EM4M per-phase sensors, indexed by phase (see Phase below)
SELEC_METER_PHASE_SENSOR(voltage)
SELEC_METER_PHASE_SENSOR(current)
SELEC_METER_PHASE_SENSOR(active_power)
SELEC_METER_PHASE_SENSOR(reactive_power)
SELEC_METER_PHASE_SENSOR(apparent_power)
SELEC_METER_PHASE_SENSOR(power_factor)
SELEC_METER_PHASE_SENSOR(import_active_energy)
SELEC_METER_PHASE_SENSOR(export_active_energy)
SELEC_METER_PHASE_SENSOR(import_reactive_energy)
SELEC_METER_PHASE_SENSOR(export_reactive_energy)
SELEC_METER_PHASE_SENSOR(apparent_energy)
// Line-to-line and aggregate sensors, not tied to one phase
SELEC_METER_SENSOR(voltage_l12)
SELEC_METER_SENSOR(voltage_l23)
SELEC_METER_SENSOR(voltage_l31)
SELEC_METER_SENSOR(average_voltage_ll)
SELEC_METER_SENSOR(net_active_energy_mains)
SELEC_METER_SENSOR(net_reactive_energy_mains)
SELEC_METER_SENSOR(net_apparent_energy_mains)
SELEC_METER_SENSOR(net_active_energy_dg)
SELEC_METER_SENSOR(net_reactive_energy_dg)
SELEC_METER_SENSOR(net_apparent_energy_dg)
#endif
void set_model(Model model) { this->model_ = model; }
// true: registers are word-swapped (LSRF / Mid Little Endian, CDAB byte order)
// false: registers are in the meter's straightforward order (MSRF / Big Endian, ABCD byte order)
void set_word_swap(bool word_swap) { this->word_swap_ = word_swap; }
#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
void set_serial_number_sensor(text_sensor::TextSensor *serial_number) { this->serial_number_sensor_ = serial_number; }
#endif
#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
void set_dg_sensing_sensor(binary_sensor::BinarySensor *dg_sensing) { this->dg_sensing_sensor_ = dg_sensing; }
#endif
void update() override;
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
modbus::ResponseStatus status) override;
bool on_no_response(std::span<const uint8_t> request_pdu) override;
void on_not_sent(std::span<const uint8_t> request_pdu) override;
void dump_config() override;
protected:
// One float field of the main block: target sensor, register and whether it is published x1000
struct FloatField {
sensor::Sensor *SelecMeter::*sensor;
uint16_t reg;
bool kilo;
};
// Publishes each configured field in `fields` (a PROGMEM table); false on a payload shorter than
// `min_count`, and the caller sets or clears the warning from the result
bool publish_fields_(std::span<const uint16_t> registers, std::span<const FloatField> fields, uint8_t min_count);
bool decode_em2m_(std::span<const uint16_t> registers);
#ifdef USE_SELEC_METER_EM4M
bool decode_em4m_(std::span<const uint16_t> registers);
#ifdef USE_TEXT_SENSOR
void decode_serial_number_(std::span<const uint16_t> registers);
#endif
#ifdef USE_BINARY_SENSOR
void decode_dg_sensing_(std::span<const uint16_t> registers);
#endif
#endif
// First register and register count of the read for `state`
struct ReadRange {
uint16_t start;
uint8_t count;
};
ReadRange read_range_(ReadState state) const;
// Whether the read cycle should run `state` on this pass
bool wanted_(ReadState state) const;
// Next wanted step after `current`, so one failed range does not cancel the other reads
ReadState next_read_state_after_(ReadState current) const;
// Sends the read for `state`; safe from a callback per the modbus callback contract
void start_read_(ReadState state);
// Clears the pending read and advances the chain. Only a MAIN_BLOCK failure sets the warning and
// only a `durable` refusal counts toward MAX_OPTIONAL_READ_FAILURES; `reason` nullptr means already logged
void fail_current_read_(const LogString *reason, bool durable = false);
#ifdef USE_SELEC_METER_EM4M
// phases_[0..2] are L1/L2/L3 (A/B/C), EM4M_PHASE_STRIDE registers apart
struct Phase {
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *current_sensor_{nullptr};
sensor::Sensor *active_power_sensor_{nullptr};
sensor::Sensor *reactive_power_sensor_{nullptr};
sensor::Sensor *apparent_power_sensor_{nullptr};
sensor::Sensor *power_factor_sensor_{nullptr};
sensor::Sensor *import_active_energy_sensor_{nullptr};
sensor::Sensor *export_active_energy_sensor_{nullptr};
sensor::Sensor *import_reactive_energy_sensor_{nullptr};
sensor::Sensor *export_reactive_energy_sensor_{nullptr};
sensor::Sensor *apparent_energy_sensor_{nullptr};
};
Phase phases_[3];
#endif
Model model_{Model::MODEL_EM2M};
bool word_swap_{false};
ReadState read_state_{ReadState::READ_STATE_IDLE};
// An optional read stops once its counter reaches MAX_OPTIONAL_READ_FAILURES
#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
text_sensor::TextSensor *serial_number_sensor_{nullptr};
// Also set to the limit once the serial number is published, since it never changes
uint8_t serial_number_failures_{0};
#endif
#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
binary_sensor::BinarySensor *dg_sensing_sensor_{nullptr};
uint8_t dg_sensing_failures_{0};
#endif
};
} // namespace esphome::selec_meter
@@ -1,30 +1,117 @@
#pragma once
#include <cstdint>
namespace esphome::selec_meter {
static const float TWO_DEC_UNIT = 0.01;
static const float ONE_DEC_UNIT = 0.1;
static const float NO_DEC_UNIT = 1;
static const float MULTIPLY_TEN_UNIT = 10;
static const float MULTIPLY_THOUSAND_UNIT = 1000;
/* PHASE STATUS REGISTERS */
static const uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000;
static const uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002;
static const uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004;
static const uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006;
static const uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008;
static const uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A;
static const uint16_t SELEC_APPARENT_ENERGY = 0x000C;
static const uint16_t SELEC_ACTIVE_POWER = 0x000E;
static const uint16_t SELEC_REACTIVE_POWER = 0x0010;
static const uint16_t SELEC_APPARENT_POWER = 0x0012;
static const uint16_t SELEC_VOLTAGE = 0x0014;
static const uint16_t SELEC_CURRENT = 0x0016;
static const uint16_t SELEC_POWER_FACTOR = 0x0018;
static const uint16_t SELEC_FREQUENCY = 0x001A;
static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C;
static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E;
static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020;
static constexpr uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000;
static constexpr uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002;
static constexpr uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004;
static constexpr uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006;
static constexpr uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008;
static constexpr uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A;
static constexpr uint16_t SELEC_APPARENT_ENERGY = 0x000C;
static constexpr uint16_t SELEC_ACTIVE_POWER = 0x000E;
static constexpr uint16_t SELEC_REACTIVE_POWER = 0x0010;
static constexpr uint16_t SELEC_APPARENT_POWER = 0x0012;
static constexpr uint16_t SELEC_VOLTAGE = 0x0014;
static constexpr uint16_t SELEC_CURRENT = 0x0016;
static constexpr uint16_t SELEC_POWER_FACTOR = 0x0018;
static constexpr uint16_t SELEC_FREQUENCY = 0x001A;
static constexpr uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C;
static constexpr uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E;
static constexpr uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020;
/* EM4M REGISTERS -- offsets are register indices from input register 30000 */
static constexpr uint8_t EM4M_REGISTER_COUNT = 114; // 114 x 16-bit registers
/* EM4M -- common quantities shared with the EM2M sensor keys */
static constexpr uint16_t EM4M_VOLTAGE = 0x0006; // Average Voltage LN
static constexpr uint16_t EM4M_CURRENT = 0x0016; // Average Current
static constexpr uint16_t EM4M_ACTIVE_POWER = 0x002A; // Total kW
static constexpr uint16_t EM4M_REACTIVE_POWER = 0x002C; // Total kVAr
static constexpr uint16_t EM4M_APPARENT_POWER = 0x002E; // Total kVA
static constexpr uint16_t EM4M_POWER_FACTOR = 0x0036; // Average PF
static constexpr uint16_t EM4M_FREQUENCY = 0x0038;
static constexpr uint16_t EM4M_MAXIMUM_DEMAND_ACTIVE_POWER = 0x0046;
static constexpr uint16_t EM4M_MAXIMUM_DEMAND_REACTIVE_POWER = 0x0048;
static constexpr uint16_t EM4M_MAXIMUM_DEMAND_APPARENT_POWER = 0x004A;
static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY = 0x0058; // Total kWh - Import
static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY = 0x005A; // Total kWh - Export
static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY = 0x0068; // Total kVArh - Import
static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY = 0x006A; // Total kVArh - Export
static constexpr uint16_t EM4M_AVERAGE_VOLTAGE_LL = 0x000E; // Average Voltage LL
// Net (import - export) energy totals, split by source. Only meaningful when the
// meter's "Dual Source" setting is Yes; DG registers read 0 on single-source installs.
static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_MAINS = 0x003A; // Total Net kWh (MAINS)
static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_MAINS = 0x003C; // Total Net kVArh (MAINS)
static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_MAINS = 0x003E; // Total Net kVAh (MAINS)
static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_DG = 0x0040; // Total Net kWh (DG)
static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_DG = 0x0042; // Total Net kVArh (DG)
static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_DG = 0x0044; // Total Net kVAh (DG)
/* EM4M -- per-phase quantities, no EM2M equivalent */
static constexpr uint16_t EM4M_VOLTAGE_L1 = 0x0000; // Voltage V1N
static constexpr uint16_t EM4M_VOLTAGE_L2 = 0x0002; // Voltage V2N
static constexpr uint16_t EM4M_VOLTAGE_L3 = 0x0004; // Voltage V3N
static constexpr uint16_t EM4M_VOLTAGE_L12 = 0x0008; // Voltage V12
static constexpr uint16_t EM4M_VOLTAGE_L23 = 0x000A; // Voltage V23
static constexpr uint16_t EM4M_VOLTAGE_L31 = 0x000C; // Voltage V31
static constexpr uint16_t EM4M_CURRENT_L1 = 0x0010;
static constexpr uint16_t EM4M_CURRENT_L2 = 0x0012;
static constexpr uint16_t EM4M_CURRENT_L3 = 0x0014;
static constexpr uint16_t EM4M_ACTIVE_POWER_L1 = 0x0018; // kW1
static constexpr uint16_t EM4M_ACTIVE_POWER_L2 = 0x001A; // kW2
static constexpr uint16_t EM4M_ACTIVE_POWER_L3 = 0x001C; // kW3
static constexpr uint16_t EM4M_REACTIVE_POWER_L1 = 0x001E; // kVAr1
static constexpr uint16_t EM4M_REACTIVE_POWER_L2 = 0x0020; // kVAr2
static constexpr uint16_t EM4M_REACTIVE_POWER_L3 = 0x0022; // kVAr3
static constexpr uint16_t EM4M_APPARENT_POWER_L1 = 0x0024; // kVA1
static constexpr uint16_t EM4M_APPARENT_POWER_L2 = 0x0026; // kVA2
static constexpr uint16_t EM4M_APPARENT_POWER_L3 = 0x0028; // kVA3
static constexpr uint16_t EM4M_POWER_FACTOR_L1 = 0x0030;
static constexpr uint16_t EM4M_POWER_FACTOR_L2 = 0x0032;
static constexpr uint16_t EM4M_POWER_FACTOR_L3 = 0x0034;
static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L1 = 0x004C; // kWh1 - Import
static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L2 = 0x004E; // kWh2 - Import
static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L3 = 0x0050; // kWh3 - Import
static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L1 = 0x0052; // kWh1 - Export
static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L2 = 0x0054; // kWh2 - Export
static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L3 = 0x0056; // kWh3 - Export
static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L1 = 0x005C; // kVArh1 - Import
static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L2 = 0x005E; // kVArh2 - Import
static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L3 = 0x0060; // kVArh3 - Import
static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L1 = 0x0062; // kVArh1 - Export
static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L2 = 0x0064; // kVArh2 - Export
static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L3 = 0x0066; // kVArh3 - Export
static constexpr uint16_t EM4M_APPARENT_ENERGY_L1 = 0x006C; // kVAh-1
static constexpr uint16_t EM4M_APPARENT_ENERGY_L2 = 0x006E; // kVAh-2
static constexpr uint16_t EM4M_APPARENT_ENERGY_L3 = 0x0070; // kVAh-3
// The decoder reads L2/L3 as the L1 register plus one or two strides; the L2/L3 names above
// document the datasheet and are checked against that stride here.
static constexpr uint16_t EM4M_PHASE_STRIDE = 2;
#define SELEC_EM4M_CHECK_STRIDE(name) \
static_assert(EM4M_##name##_L2 == EM4M_##name##_L1 + EM4M_PHASE_STRIDE && \
EM4M_##name##_L3 == EM4M_##name##_L1 + 2 * EM4M_PHASE_STRIDE, \
#name " per-phase registers must be EM4M_PHASE_STRIDE apart");
SELEC_EM4M_CHECK_STRIDE(VOLTAGE)
SELEC_EM4M_CHECK_STRIDE(CURRENT)
SELEC_EM4M_CHECK_STRIDE(ACTIVE_POWER)
SELEC_EM4M_CHECK_STRIDE(REACTIVE_POWER)
SELEC_EM4M_CHECK_STRIDE(APPARENT_POWER)
SELEC_EM4M_CHECK_STRIDE(POWER_FACTOR)
SELEC_EM4M_CHECK_STRIDE(IMPORT_ACTIVE_ENERGY)
SELEC_EM4M_CHECK_STRIDE(EXPORT_ACTIVE_ENERGY)
SELEC_EM4M_CHECK_STRIDE(IMPORT_REACTIVE_ENERGY)
SELEC_EM4M_CHECK_STRIDE(EXPORT_REACTIVE_ENERGY)
SELEC_EM4M_CHECK_STRIDE(APPARENT_ENERGY)
#undef SELEC_EM4M_CHECK_STRIDE
// Far outside the main 0-113 register block; read via a separate Modbus transaction.
static constexpr uint16_t EM4M_SERIAL_NUMBER = 0x02AC; // Serial No. (Hex), 30684
static constexpr uint16_t EM4M_DG_SENSING = 0x02C6; // DG Sensing (1: Pass, 0: Fail), 30710
} // namespace esphome::selec_meter
+239 -23
View File
@@ -1,5 +1,11 @@
import esphome.codegen as cg
from esphome.components import modbus, sensor
from esphome.components import binary_sensor, modbus, sensor, text_sensor
from esphome.components.const import (
BYTE_ORDER_BIG,
BYTE_ORDER_LITTLE,
CONF_BYTE_ORDER,
CONF_SERIAL_NUMBER,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTIVE_POWER,
@@ -11,6 +17,10 @@ from esphome.const import (
CONF_ID,
CONF_IMPORT_ACTIVE_ENERGY,
CONF_IMPORT_REACTIVE_ENERGY,
CONF_MODEL,
CONF_PHASE_A,
CONF_PHASE_B,
CONF_PHASE_C,
CONF_POWER_FACTOR,
CONF_REACTIVE_POWER,
CONF_VOLTAGE,
@@ -24,6 +34,7 @@ from esphome.const import (
DEVICE_CLASS_VOLTAGE,
ICON_CURRENT_AC,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_HERTZ,
@@ -37,9 +48,22 @@ from esphome.const import (
)
from esphome.types import ConfigType
AUTO_LOAD = ["modbus"]
CODEOWNERS = ["@sourabhjaiswal"]
CONF_DG_SENSING = "dg_sensing"
def AUTO_LOAD(config: ConfigType) -> list[str]:
return ["modbus"] + [
component
for key, component in (
(CONF_SERIAL_NUMBER, "text_sensor"),
(CONF_DG_SENSING, "binary_sensor"),
)
if not config or key in config
]
CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy"
CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy"
CONF_APPARENT_ENERGY = "apparent_energy"
@@ -47,19 +71,68 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power"
CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power"
CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power"
CONF_VOLTAGE_L12 = "voltage_l12"
CONF_VOLTAGE_L23 = "voltage_l23"
CONF_VOLTAGE_L31 = "voltage_l31"
CONF_AVERAGE_VOLTAGE_LL = "average_voltage_ll"
CONF_NET_ACTIVE_ENERGY_MAINS = "net_active_energy_mains"
CONF_NET_REACTIVE_ENERGY_MAINS = "net_reactive_energy_mains"
CONF_NET_APPARENT_ENERGY_MAINS = "net_apparent_energy_mains"
CONF_NET_ACTIVE_ENERGY_DG = "net_active_energy_dg"
CONF_NET_REACTIVE_ENERGY_DG = "net_reactive_energy_dg"
CONF_NET_APPARENT_ENERGY_DG = "net_apparent_energy_dg"
selec_meter_ns = cg.esphome_ns.namespace("selec_meter")
SelecMeter = selec_meter_ns.class_(
"SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice
)
SelecMeterModel = selec_meter_ns.enum("Model", is_class=True)
SENSORS = {
MODEL_EM2M = "em2m"
MODEL_EM4M = "em4m"
MODELS = {
MODEL_EM2M: SelecMeterModel.MODEL_EM2M,
MODEL_EM4M: SelecMeterModel.MODEL_EM4M,
}
# Register word order of the FP32 values: big_endian sends the high word first (ABCD),
# little_endian the low word first (CDAB). msrf/lsrf are aliases matching the meter's own
# register 40070 (Endianness Selection) menu names.
BYTE_ORDER_MSRF = "msrf"
BYTE_ORDER_LSRF = "lsrf"
# Maps the YAML option directly to the C++ word_swap bool -- no enum needed on the C++ side.
BYTE_ORDER_WORD_SWAP = {
BYTE_ORDER_BIG: False,
BYTE_ORDER_LITTLE: True,
BYTE_ORDER_MSRF: False,
BYTE_ORDER_LSRF: True,
}
validate_byte_order = cv.enum(BYTE_ORDER_WORD_SWAP, lower=True)
# EM2M-only: total combined energy figures with no non-DG equivalent on the EM4M.
EM2M_SENSORS = {
CONF_TOTAL_ACTIVE_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=2,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
CONF_APPARENT_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS,
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
}
# Sensors available on both EM2M and EM4M. On EM4M these read the aggregate
# (Total/Average) registers rather than a single-phase measurement.
COMMON_SENSORS = {
CONF_IMPORT_ACTIVE_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=2,
@@ -72,11 +145,6 @@ SENSORS = {
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
CONF_IMPORT_REACTIVE_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=2,
@@ -87,11 +155,6 @@ SENSORS = {
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
CONF_APPARENT_ENERGY: sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS,
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
CONF_ACTIVE_POWER: sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=3,
@@ -154,13 +217,115 @@ SENSORS = {
),
}
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)})
.extend(
{cv.Optional(sensor_name): schema for sensor_name, schema in SENSORS.items()}
def _sensor_group(*keys: str, **schema_kwargs) -> dict[str, cv.Schema]:
"""One identical sensor_schema() per key."""
return {key: sensor.sensor_schema(**schema_kwargs) for key in keys}
# EM4M-only, genuinely per-phase quantities, nested under phase_a/phase_b/phase_c like
# sdm_meter/atm90e32 do for 3-phase meters.
PHASE_SENSORS = {
key: {**COMMON_SENSORS, **EM2M_SENSORS}[key]
for key in (
CONF_VOLTAGE,
CONF_CURRENT,
CONF_ACTIVE_POWER,
CONF_REACTIVE_POWER,
CONF_APPARENT_POWER,
CONF_POWER_FACTOR,
CONF_IMPORT_ACTIVE_ENERGY,
CONF_EXPORT_ACTIVE_ENERGY,
CONF_IMPORT_REACTIVE_ENERGY,
CONF_EXPORT_REACTIVE_ENERGY,
CONF_APPARENT_ENERGY,
)
.extend(cv.polling_component_schema("10s"))
.extend(modbus.modbus_device_schema(0x01))
}
PHASE_SCHEMA = cv.Schema(
{cv.Optional(key): schema for key, schema in PHASE_SENSORS.items()}
)
# EM4M-only, line-to-line and mains/DG net totals -- these don't map to a single phase, so they
# stay flat rather than living under phase_a/phase_b/phase_c.
EM4M_SENSORS = {
**_sensor_group(
CONF_VOLTAGE_L12,
CONF_VOLTAGE_L23,
CONF_VOLTAGE_L31,
CONF_AVERAGE_VOLTAGE_LL,
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=2,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
# Net (import - export) energy totals, split by source. Only meaningful when the meter's
# "Dual Source" setting is Yes; DG registers read 0 on single-source installs.
**_sensor_group(
CONF_NET_ACTIVE_ENERGY_MAINS,
CONF_NET_ACTIVE_ENERGY_DG,
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=2,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL,
),
**_sensor_group(
CONF_NET_REACTIVE_ENERGY_MAINS,
CONF_NET_REACTIVE_ENERGY_DG,
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL,
),
**_sensor_group(
CONF_NET_APPARENT_ENERGY_MAINS,
CONF_NET_APPARENT_ENERGY_DG,
unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS,
accuracy_decimals=2,
state_class=STATE_CLASS_TOTAL,
),
}
PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C)
def _optional(sensors: dict[str, cv.Schema]) -> dict[cv.Optional, cv.Schema]:
return {cv.Optional(key): schema for key, schema in sensors.items()}
_BASE_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}).extend(
cv.polling_component_schema("10s"), modbus.modbus_device_schema(0x01)
)
# EM2M keeps this component's original word-swapped decode; EM4M uses the meter's factory
# default (MSRF / Big Endian), confirmed against real hardware.
CONFIG_SCHEMA = cv.typed_schema(
{
MODEL_EM2M: _BASE_SCHEMA.extend(
{
cv.Optional(
CONF_BYTE_ORDER, default=BYTE_ORDER_LITTLE
): validate_byte_order,
**_optional(COMMON_SENSORS),
**_optional(EM2M_SENSORS),
}
),
MODEL_EM4M: _BASE_SCHEMA.extend(
{
cv.Optional(
CONF_BYTE_ORDER, default=BYTE_ORDER_BIG
): validate_byte_order,
**_optional(COMMON_SENSORS),
**_optional(EM4M_SENSORS),
**{cv.Optional(phase): PHASE_SCHEMA for phase in PHASES},
cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(),
cv.Optional(CONF_DG_SENSING): binary_sensor.binary_sensor_schema(),
}
),
},
key=CONF_MODEL,
default_type=MODEL_EM2M,
enum=MODELS,
lower=True,
)
@@ -175,7 +340,58 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(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])
cg.add(getattr(var, f"set_{name}_sensor")(sens))
cg.add(var.set_model(config[CONF_MODEL]))
cg.add(var.set_word_swap(config[CONF_BYTE_ORDER]))
if config[CONF_MODEL] == MODEL_EM4M:
cg.add_define("USE_SELEC_METER_EM4M")
sensors = sensor.sub_sensors(config)
await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor)
await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor)
await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor)
await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor)
await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor)
await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor)
await sensors(CONF_APPARENT_ENERGY, var.set_apparent_energy_sensor)
await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor)
await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor)
await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor)
await sensors(CONF_FREQUENCY, var.set_frequency_sensor)
await sensors(
CONF_MAXIMUM_DEMAND_ACTIVE_POWER, var.set_maximum_demand_active_power_sensor
)
await sensors(
CONF_MAXIMUM_DEMAND_REACTIVE_POWER, var.set_maximum_demand_reactive_power_sensor
)
await sensors(
CONF_MAXIMUM_DEMAND_APPARENT_POWER, var.set_maximum_demand_apparent_power_sensor
)
await sensors(CONF_VOLTAGE_L12, var.set_voltage_l12_sensor)
await sensors(CONF_VOLTAGE_L23, var.set_voltage_l23_sensor)
await sensors(CONF_VOLTAGE_L31, var.set_voltage_l31_sensor)
await sensors(CONF_AVERAGE_VOLTAGE_LL, var.set_average_voltage_ll_sensor)
await sensors(CONF_NET_ACTIVE_ENERGY_MAINS, var.set_net_active_energy_mains_sensor)
await sensors(
CONF_NET_REACTIVE_ENERGY_MAINS, var.set_net_reactive_energy_mains_sensor
)
await sensors(
CONF_NET_APPARENT_ENERGY_MAINS, var.set_net_apparent_energy_mains_sensor
)
await sensors(CONF_NET_ACTIVE_ENERGY_DG, var.set_net_active_energy_dg_sensor)
await sensors(CONF_NET_REACTIVE_ENERGY_DG, var.set_net_reactive_energy_dg_sensor)
await sensors(CONF_NET_APPARENT_ENERGY_DG, var.set_net_apparent_energy_dg_sensor)
for phase_index, phase in enumerate(PHASES):
if (phase_config := config.get(phase)) is None:
continue
for name in PHASE_SENSORS:
if name in phase_config:
sens = await sensor.new_sensor(phase_config[name])
cg.add(getattr(var, f"set_{name}_sensor")(phase_index, sens))
await text_sensor.sub_text_sensors(config)(
CONF_SERIAL_NUMBER, var.set_serial_number_sensor
)
await binary_sensor.sub_binary_sensors(config)(
CONF_DG_SENSING, var.set_dg_sensing_sensor
)
+23 -9
View File
@@ -113,7 +113,7 @@ from esphome.const import (
DEVICE_CLASS_WIND_SPEED,
ENTITY_CATEGORY_CONFIG,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
SubEntities,
@@ -421,16 +421,30 @@ async def multiply_filter_to_code(config, filter_id):
return cg.new_Pvariable(filter_id, template_)
TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_)
async def _value_list_table(values: list[Any]) -> MockObj:
"""PROGMEM value table, ended by an empty entry so filters store no count."""
fns = [await cg.templatable(x, [], cg.float_) for x in values]
# Identical lambdas (e.g. a YAML anchor) may keep static state, so only constants share.
return cg.shared_progmem_array(
"sensor_value_list",
TemplatableFloat,
[*fns, TemplatableFloat()],
share=not any(isinstance(v, Lambda) for v in values),
)
@FILTER_REGISTRY.register(
"filter_out",
FilterOutValueFilter,
cv.Any(cv.templatable(cv.float_), [cv.templatable(cv.float_)]),
)
async def filter_out_filter_to_code(config, filter_id):
async def filter_out_filter_to_code(config: Any, filter_id: ID) -> MockObj:
if not isinstance(config, list):
config = [config]
template_ = [await cg.templatable(x, [], cg.float_) for x in config]
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_)
return cg.new_Pvariable(filter_id, await _value_list_table(config))
QUANTILE_SCHEMA = cv.All(
@@ -704,7 +718,9 @@ def _is_nan_only(values: Any) -> bool:
ThrottleWithPriorityFilter,
THROTTLE_WITH_PRIORITY_SCHEMA,
)
async def throttle_with_priority_filter_to_code(config, filter_id):
async def throttle_with_priority_filter_to_code(
config: ConfigType, filter_id: ID
) -> MockObj:
values = config[CONF_VALUE]
if not isinstance(values, list):
values = [values]
@@ -716,10 +732,8 @@ async def throttle_with_priority_filter_to_code(config, filter_id):
filter_id = filter_id.copy()
filter_id.type = ThrottleWithPriorityNanFilter
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT])
template_ = [await cg.templatable(x, [], cg.float_) for x in values]
return cg.new_Pvariable(
filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_
)
table = await _value_list_table(values)
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], table)
HEARTBEAT_SCHEMA = cv.Schema(
+10 -11
View File
@@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn<float> multiplier) : multiplier_(mu
optional<float> MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); }
// ValueListFilter helper (non-template, shared by all ValueListFilter<N> instantiations)
bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn<float> *values, size_t count) {
int8_t accuracy = parent->get_accuracy_decimals();
// ValueListFilter
bool ValueListFilter::value_matches_any_(float sensor_value) {
int8_t accuracy = this->parent_->get_accuracy_decimals();
float accuracy_mult = pow10_int(accuracy);
float rounded_sensor = roundf(accuracy_mult * sensor_value);
for (size_t i = 0; i < count; i++) {
float fv = values[i].value();
for (const TemplatableFn<float> *v = this->values_; v->has_value(); v++) {
float fv = v->value();
// Handle NaN comparison
if (std::isnan(fv)) {
@@ -261,13 +261,12 @@ optional<float> ThrottleFilter::new_value(float value) {
return {};
}
// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp)
optional<float> throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn<float> *values,
size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) {
// ThrottleWithPriorityFilter
optional<float> ThrottleWithPriorityFilter::new_value(float value) {
const uint32_t now = App.get_loop_component_start_time();
if (last_input == 0 || now - last_input >= min_time_between_inputs ||
value_list_matches_any(parent, value, values, count)) {
last_input = now;
if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ ||
this->value_matches_any_(value)) {
this->last_input_ = now;
return value;
}
return {};
+16 -33
View File
@@ -330,36 +330,27 @@ class MultiplyFilter : public Filter {
TemplatableFn<float> multiplier_;
};
/// Non-template helper for value matching (implementation in filter.cpp)
bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn<float> *values, size_t count);
// Value tables are read in place from flash, which ESP8266 only allows for whole words.
static_assert(sizeof(TemplatableFn<float>) == sizeof(void *), "TemplatableFn<float> must stay a single pointer");
/** Base class for filters that compare sensor values against a fixed list of configured values.
*
* Templated on N (the number of values) so the list is stored inline in a std::array,
* avoiding heap allocation and the overhead of FixedVector.
*
* @tparam N Number of values in the filter list, set by code generation to match
* the exact number of values configured in YAML.
*/
template<size_t N> class ValueListFilter : public Filter {
/// Base class for filters that compare sensor values against a fixed list of configured values.
class ValueListFilter : public Filter {
protected:
explicit ValueListFilter(std::initializer_list<TemplatableFn<float>> values) {
init_array_from(this->values_, values);
}
explicit ValueListFilter(const TemplatableFn<float> *values) : values_(values) {}
/// Check if sensor value matches any configured value (with accuracy rounding)
bool value_matches_any_(float sensor_value) {
return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N);
}
bool value_matches_any_(float sensor_value);
std::array<TemplatableFn<float>, N> values_{};
// Shared PROGMEM table ended by an empty entry, so no count is stored; entries are function
// pointers, so aligned 32-bit loads from flash are safe on ESP8266.
const TemplatableFn<float> *values_;
};
/// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`.
template<size_t N> class FilterOutValueFilter : public ValueListFilter<N> {
class FilterOutValueFilter : public ValueListFilter {
public:
explicit FilterOutValueFilter(std::initializer_list<TemplatableFn<float>> values_to_filter_out)
: ValueListFilter<N>(values_to_filter_out) {}
explicit FilterOutValueFilter(const TemplatableFn<float> *values_to_filter_out)
: ValueListFilter(values_to_filter_out) {}
optional<float> new_value(float value) override {
if (this->value_matches_any_(value))
@@ -379,21 +370,13 @@ class ThrottleFilter : public Filter {
uint32_t min_time_between_inputs_;
};
/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp)
optional<float> throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn<float> *values,
size_t count, uint32_t &last_input, uint32_t min_time_between_inputs);
/// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`.
template<size_t N> class ThrottleWithPriorityFilter : public ValueListFilter<N> {
class ThrottleWithPriorityFilter : public ValueListFilter {
public:
explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs,
std::initializer_list<TemplatableFn<float>> prioritized_values)
: ValueListFilter<N>(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn<float> *prioritized_values)
: ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
optional<float> new_value(float value) override {
return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_,
this->min_time_between_inputs_);
}
optional<float> new_value(float value) override;
protected:
uint32_t last_input_{0};
+4 -6
View File
@@ -12,14 +12,15 @@ enum class MeasurementMode : uint8_t {
};
class STCC4Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice {
SUB_SENSOR(co2)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_co2_sensor(sensor::Sensor *co2) { this->co2_sensor_ = co2; }
void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; }
void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; }
void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; }
void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; }
void set_ambient_pressure_compensation(float pressure_in_hpa);
@@ -36,9 +37,6 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S
bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks);
bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2);
sensor::Sensor *co2_sensor_{nullptr};
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
sensor::Sensor *temperature_source_{nullptr};
sensor::Sensor *humidity_source_{nullptr};
sensor::Sensor *ambient_pressure_source_{nullptr};
+1 -2
View File
@@ -1,13 +1,12 @@
import esphome.codegen as cg
from esphome.components import text_sensor
from esphome.components.const import CONF_SERIAL_NUMBER
import esphome.config_validation as cv
from esphome.const import CONF_STATE
from esphome.types import ConfigType
from . import CONF_SUN_GTIL2_ID, SunGTIL2Component
CONF_SERIAL_NUMBER = "serial_number"
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
+38 -3
View File
@@ -1,10 +1,14 @@
"""Shared TMP102 types and configuration validation."""
from dataclasses import dataclass, field
import math
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import CONF_CONVERSION_RATE
import esphome.config_validation as cv
from esphome.const import CONF_PLATFORM
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -16,6 +20,7 @@ tmp102_ns = cg.esphome_ns.namespace("tmp102")
TMP102Component = tmp102_ns.class_(
"TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor
)
TMP102LimitType = tmp102_ns.enum("TMP102LimitType")
CONF_EXTENDED_MODE = "extended_mode"
CONF_ONE_SHOT_MODE = "one_shot_mode"
@@ -34,10 +39,40 @@ TMP102_CONVERSION_FACTOR = 0.0625
TMP102_DEFAULT_THIGH = 80.0
TMP102_DEFAULT_TLOW = 75.0
TMP102_MIN_TEMPERATURE = -55.0
TMP102_MAX_TEMPERATURE = 127.9375
TMP102_MAX_TEMPERATURE_EXTENDED = 150.0
def max_temperature(extended: bool) -> float:
return TMP102_MAX_TEMPERATURE_EXTENDED if extended else TMP102_MAX_TEMPERATURE
@dataclass
class TMP102Data:
# Extended mode of each sensor that has threshold numbers, keyed by sensor ID.
number_parents: dict[str, bool] = field(default_factory=dict)
def _get_data() -> TMP102Data:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = TMP102Data()
return CORE.data[DOMAIN]
def add_number_parent(parent_id: object, extended: bool) -> None:
_get_data().number_parents[str(parent_id)] = extended
def number_parent_extended(parent_id: object) -> bool | None:
"""Return the extended mode of a sensor with threshold numbers, or None without numbers."""
return _get_data().number_parents.get(str(parent_id))
def encode_temperature(temperature: float, extended: bool) -> int:
"""Encode a temperature as a TMP102 limit-register word."""
steps = round(temperature / TMP102_CONVERSION_FACTOR)
scaled = temperature / TMP102_CONVERSION_FACTOR
steps = int(scaled + math.copysign(0.5, scaled))
return (steps << (3 if extended else 4)) & 0xFFFF
@@ -56,8 +91,8 @@ def build_configuration(config: ConfigType) -> int:
def validate_tmp102_thresholds(config: ConfigType) -> ConfigType:
extended = config.get(CONF_EXTENDED_MODE, False)
t_min = -55.0
t_max = 150.0 if extended else 127.9375
t_min = TMP102_MIN_TEMPERATURE
t_max = max_temperature(extended)
high = config.get(CONF_TEMPERATURE_HIGH)
low = config.get(CONF_TEMPERATURE_LOW)
for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)):
+87
View File
@@ -0,0 +1,87 @@
"""Adjustable TMP102 thermostat thresholds."""
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
CONF_RESTORE_VALUE,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_CONFIG,
UNIT_CELSIUS,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import (
CONF_EXTENDED_MODE,
CONF_TEMPERATURE_HIGH,
CONF_TEMPERATURE_LOW,
CONF_TMP102_ID,
TMP102_CONVERSION_FACTOR,
TMP102_MIN_TEMPERATURE,
TMP102Component,
TMP102LimitType,
add_number_parent,
max_temperature,
number_parent_extended,
tmp102_ns,
validate_unique_child,
)
TMP102LimitNumber = tmp102_ns.class_(
"TMP102LimitNumber", number.Number, cg.Parented.template(TMP102Component)
)
_LIMIT_SCHEMA = number.number_schema(
TMP102LimitNumber,
unit_of_measurement=UNIT_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
entity_category=ENTITY_CATEGORY_CONFIG,
).extend({cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean})
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component),
cv.Optional(CONF_TEMPERATURE_HIGH): _LIMIT_SCHEMA,
cv.Optional(CONF_TEMPERATURE_LOW): _LIMIT_SCHEMA,
}
),
cv.has_at_least_one_key(CONF_TEMPERATURE_HIGH, CONF_TEMPERATURE_LOW),
)
def _final_validate(config: ConfigType) -> ConfigType:
validate_unique_child(config, "number")
full = fv.full_config.get()
parent = full.get_config_for_path(full.get_path_for_id(config[CONF_TMP102_ID])[:-1])
add_number_parent(config[CONF_TMP102_ID], parent.get(CONF_EXTENDED_MODE, False))
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_TMP102_NUMBER")
parent = await cg.get_variable(config[CONF_TMP102_ID])
limits = {
"min_value": TMP102_MIN_TEMPERATURE,
"max_value": max_temperature(number_parent_extended(config[CONF_TMP102_ID])),
"step": TMP102_CONVERSION_FACTOR,
}
numbers = number.sub_numbers(config, parent=parent)
if var := await numbers(
CONF_TEMPERATURE_HIGH,
parent.set_limit_control,
TMP102LimitType.TMP102_LIMIT_HIGH,
**limits,
):
cg.add(var.set_restore_value(config[CONF_TEMPERATURE_HIGH][CONF_RESTORE_VALUE]))
if var := await numbers(
CONF_TEMPERATURE_LOW,
parent.set_limit_control,
TMP102LimitType.TMP102_LIMIT_LOW,
**limits,
):
cg.add(var.set_restore_value(config[CONF_TEMPERATURE_LOW][CONF_RESTORE_VALUE]))
+10 -5
View File
@@ -5,6 +5,7 @@ from esphome.components import i2c, sensor
from esphome.components.const import CONF_CONVERSION_RATE
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
@@ -28,6 +29,7 @@ from . import (
TMP102Component,
build_configuration,
encode_temperature,
number_parent_extended,
validate_tmp102_thresholds,
)
@@ -66,17 +68,20 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if not any(key in config for key in _ADVANCED_SCHEMA):
has_numbers = number_parent_extended(config[CONF_ID]) is not None
if not has_numbers and not any(key in config for key in _ADVANCED_SCHEMA):
return
extended = config.get(CONF_EXTENDED_MODE, False)
high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH)
low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW)
cg.add_define("USE_TMP102_CONFIGURE")
cg.add(
var.set_configuration(
build_configuration(config),
encode_temperature(high, extended),
encode_temperature(low, extended),
encode_temperature(
config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH), extended
),
encode_temperature(
config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW), extended
),
)
)
+118 -1
View File
@@ -2,6 +2,8 @@
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#include <cmath>
namespace esphome::tmp102 {
ESPHOME_LOG_TAG(TAG, "tmp102");
@@ -18,6 +20,9 @@ static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400;
#ifdef USE_TMP102_CONFIGURE
static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02;
static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03;
#ifdef USE_TMP102_NUMBER
static_assert(TMP102_REGISTER_LOW_LIMIT + TMP102_LIMIT_HIGH == TMP102_REGISTER_HIGH_LIMIT);
#endif
static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0;
static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0;
@@ -31,11 +36,39 @@ static float decode_temperature(uint16_t raw, bool extended) {
return (static_cast<int16_t>(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR;
}
#ifdef USE_TMP102_NUMBER
static uint16_t encode_temperature(float temperature, bool extended) {
const int16_t steps = static_cast<int16_t>(std::lround(temperature / TMP102_CONVERSION_FACTOR));
return static_cast<uint16_t>(static_cast<uint32_t>(static_cast<uint16_t>(steps)) << (extended ? 3 : 4));
}
#endif
void TMP102Component::setup() {
if (!this->configure_)
return;
if (!this->write_configuration_())
#ifdef USE_TMP102_NUMBER
float restored[2]{};
this->prepare_number_limits_(restored);
#endif
if (!this->write_configuration_()) {
this->mark_failed();
return;
}
#ifdef USE_TMP102_NUMBER
bool saved = true;
for (uint8_t limit = 0; limit < 2; ++limit) {
TMP102LimitNumber *control = this->limit_controls_[limit];
if (control == nullptr)
continue;
const float value = this->get_limit_temperature_(static_cast<TMP102LimitType>(limit));
if (value != restored[limit])
saved &= control->save_value_(value);
control->publish_state(value);
}
if (!saved) {
ESP_LOGW(TAG, "Failed to save threshold preferences");
}
#endif
}
#endif
@@ -75,6 +108,10 @@ void TMP102Component::dump_config() {
this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"),
fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended),
decode_temperature(this->low_limit_, extended));
#ifdef USE_TMP102_NUMBER
LOG_NUMBER(" ", "High Limit", this->limit_controls_[TMP102_LIMIT_HIGH]);
LOG_NUMBER(" ", "Low Limit", this->limit_controls_[TMP102_LIMIT_LOW]);
#endif
if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) {
ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin");
}
@@ -185,6 +222,55 @@ void TMP102Component::read_temperature_() {
#endif
this->status_clear_warning();
}
#ifdef USE_TMP102_NUMBER
void TMP102Component::set_limit_control(TMP102LimitNumber *number) {
this->limit_controls_[number->limit_type_] = number;
}
void TMP102Component::prepare_number_limits_(float *restored) {
const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT;
uint16_t raw[2] = {this->low_limit_, this->high_limit_};
for (uint8_t limit = 0; limit < 2; ++limit) {
TMP102LimitNumber *control = this->limit_controls_[limit];
if (control == nullptr)
continue;
restored[limit] = control->load_value_(decode_temperature(raw[limit], extended));
raw[limit] = encode_temperature(restored[limit], extended);
}
if (static_cast<int16_t>(raw[TMP102_LIMIT_LOW]) > static_cast<int16_t>(raw[TMP102_LIMIT_HIGH])) {
ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values");
return;
}
this->low_limit_ = raw[TMP102_LIMIT_LOW];
this->high_limit_ = raw[TMP102_LIMIT_HIGH];
}
float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const {
const uint16_t raw = limit == TMP102_LIMIT_HIGH ? this->high_limit_ : this->low_limit_;
return decode_temperature(raw, this->config_ & TMP102_EXTENDED_MODE_BIT);
}
bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temperature) {
const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT;
const uint16_t raw = encode_temperature(temperature, extended);
const bool high = limit == TMP102_LIMIT_HIGH;
const uint16_t other = high ? this->low_limit_ : this->high_limit_;
if (high ? static_cast<int16_t>(raw) < static_cast<int16_t>(other)
: static_cast<int16_t>(raw) > static_cast<int16_t>(other)) {
ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C",
high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended),
high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended));
return false;
}
if (!this->write_register_(static_cast<uint8_t>(TMP102_REGISTER_LOW_LIMIT + limit), raw)) {
this->status_set_warning();
return false;
}
(high ? this->high_limit_ : this->low_limit_) = raw;
return true;
}
#endif
#endif
#ifdef USE_TMP102_BINARY_SENSOR
@@ -207,4 +293,35 @@ bool TMP102Component::read_alert_state_() {
}
#endif
#ifdef USE_TMP102_NUMBER
float TMP102LimitNumber::load_value_(float fallback) {
float value = fallback;
if (this->restore_value_) {
this->pref_ = this->make_entity_preference<float>();
this->pref_.load(&value);
}
if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) {
ESP_LOGW(TAG, "Invalid restored threshold; using configured value");
value = fallback;
}
return value;
}
bool TMP102LimitNumber::save_value_(float value) { return !this->restore_value_ || this->pref_.save(&value); }
void TMP102LimitNumber::control(float value) {
// A chip that failed setup holds nothing the entity could show
if (this->parent_->is_failed()) {
ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed");
return;
}
const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value);
value = this->parent_->get_limit_temperature_(this->limit_type_);
this->publish_state(value);
if (accepted && !this->save_value_(value)) {
ESP_LOGW(TAG, "Failed to save threshold preference");
}
}
#endif
} // namespace esphome::tmp102
+43
View File
@@ -6,12 +6,26 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#include "esphome/components/i2c/i2c.h"
#ifdef USE_TMP102_NUMBER
#include "esphome/components/number/number.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#endif
#include "esphome/components/sensor/sensor.h"
#include <cstdint>
namespace esphome::tmp102 {
#ifdef USE_TMP102_NUMBER
enum TMP102LimitType : uint8_t {
TMP102_LIMIT_LOW = 0,
TMP102_LIMIT_HIGH = 1,
};
class TMP102LimitNumber;
#endif
class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor {
public:
#ifdef USE_TMP102_BINARY_SENSOR
@@ -25,6 +39,9 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
this->low_limit_ = low_limit;
this->configure_ = true;
}
#ifdef USE_TMP102_NUMBER
void set_limit_control(TMP102LimitNumber *number);
#endif
#endif
void dump_config() override;
void update() override;
@@ -46,6 +63,14 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
bool check_configuration_();
void read_temperature_();
void update_configured_();
#ifdef USE_TMP102_NUMBER
friend class TMP102LimitNumber;
bool set_limit_temperature_(TMP102LimitType limit, float temperature);
float get_limit_temperature_(TMP102LimitType limit) const;
void prepare_number_limits_(float *restored);
TMP102LimitNumber *limit_controls_[2]{};
#endif
uint16_t config_{0};
uint16_t high_limit_{0};
@@ -54,4 +79,22 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
#endif
};
#ifdef USE_TMP102_NUMBER
class TMP102LimitNumber final : public number::Number, public Parented<TMP102Component> {
public:
explicit TMP102LimitNumber(TMP102LimitType limit_type) : limit_type_(limit_type) {}
void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; }
protected:
friend class TMP102Component;
float load_value_(float fallback);
bool save_value_(float value);
void control(float value) override;
ESPPreferenceObject pref_;
TMP102LimitType limit_type_;
bool restore_value_{true};
};
#endif
} // namespace esphome::tmp102
@@ -24,6 +24,8 @@ static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000;
__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; }
bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool ESP8266UartComponent::serial0_swapped = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
uint32_t ESP8266UartComponent::get_config() {
uint32_t config = 0;
@@ -111,6 +113,7 @@ void ESP8266UartComponent::setup() {
this->hw_serial_->setRxBufferSize(this->rx_buffer_size_);
this->hw_serial_->swap();
ESP8266UartComponent::serial0_in_use = true;
ESP8266UartComponent::serial0_swapped = true;
} else
#endif // USE_ESP8266_UART_SERIAL
#ifdef USE_ESP8266_UART_SERIAL1
@@ -133,6 +136,11 @@ void ESP8266UartComponent::load_settings(bool dump_config) {
SerialConfig config = static_cast<SerialConfig>(get_config());
this->hw_serial_->begin(this->baud_rate_, config);
this->hw_serial_->setRxBufferSize(this->rx_buffer_size_);
#ifdef USE_ESP8266_UART_SERIAL
// begin() puts UART0 back on GPIO1/GPIO3, so swap it again like setup() did for GPIO15/GPIO13
if (this->hw_serial_ == &Serial && ESP8266UartComponent::serial0_swapped)
this->hw_serial_->swap();
#endif // USE_ESP8266_UART_SERIAL
} else {
this->sw_serial_->setup(this->tx_pin_, this->rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_,
this->parity_, this->rx_buffer_size_);
@@ -92,7 +92,8 @@ class ESP8266UartComponent final : public UARTComponent, public Component {
ESP8266SoftwareSerial *sw_serial_{nullptr};
private:
static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool serial0_swapped; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
};
} // namespace esphome::uart
+3 -2
View File
@@ -19,11 +19,12 @@ namespace esphome {
/// Codegen wraps constants in stateless lambdas so only a function pointer is needed.
template<typename T, typename... X> class TemplatableFn {
public:
TemplatableFn() = default;
constexpr TemplatableFn() = default;
TemplatableFn(std::nullptr_t) = delete;
// Exact return type match — direct function pointer storage
template<typename F> TemplatableFn(F f) requires std::convertible_to<F, T (*)(X...)> : f_(f) {}
// constexpr so codegen can place arrays of these in flash.
template<typename F> constexpr TemplatableFn(F f) requires std::convertible_to<F, T (*)(X...)> : f_(f) {}
// Convertible return type (e.g., int -> uint8_t) — casting trampoline.
// Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside
+9
View File
@@ -49,6 +49,9 @@
#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
#define USE_BLE_DEVICE_IRK
#define USE_BMI270_BMM150
#define USE_BQ25186_PG_GPO
#define BQ25186_BINARY_SENSOR_COUNT 1
#define BQ25186_TEXT_SENSOR_COUNT 1
#define USE_BUTTON
#define USE_CAMERA
#define USE_CLIMATE
@@ -88,12 +91,16 @@
#define USE_I2S_AUDIO_SPDIF_MODE
#define USE_IMAGE
#define USE_IMPROV_BLE_STATE_CALLBACK
#define USE_INA3221_ALERT_LIMITS
#define USE_INA3221_SUMMATION
#define USE_INFRARED
#define USE_IR_RF
#define USE_IR_RF_TRANSMIT_COMPLETE
#define USE_JSON
#define USE_JSON_ARENA
#define USE_RADIO_FREQUENCY
#define USE_LD2410S_PARAMETERS
#define USE_LD2410S_VERSION
#define USE_LIGHT
#define USE_LIGHT_FLASH_TRANSITION_LENGTH
#define USE_LIGHT_GAMMA_LUT
@@ -244,6 +251,7 @@
#define USE_SAFE_MODE_CALLBACK
#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1
#define USE_SELECT
#define USE_SELEC_METER_EM4M
#define USE_SENSOR
#define USE_SENSOR_FILTER
#define USE_SERIAL_PROXY
@@ -259,6 +267,7 @@
#define USE_TIME_TRIGGERS
#define USE_TMP102_BINARY_SENSOR
#define USE_TMP102_CONFIGURE
#define USE_TMP102_NUMBER
#define USE_TOUCHSCREEN
#define USE_UART_DEBUGGER
#define USE_UART_WAKE_LOOP_ON_RX
+19
View File
@@ -188,6 +188,25 @@ inline bool operator==(const __FlashStringHelper *lhs, const StringRef &rhs) { r
inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { return !(lhs == rhs); }
inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); }
/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen.
struct ProgmemStringRef {
PGM_P str;
size_t len;
};
inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) {
return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0);
}
inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); }
inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) {
// A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes
return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0);
}
inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); }
#endif // USE_ESP8266
inline bool operator<(const StringRef &lhs, const StringRef &rhs) {
+14 -1
View File
@@ -191,10 +191,23 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any:
If the value is already tagged, the new context vars are merged with existing ones,
with new vars taking precedence. Returns the value tagged with ConfigContext. Returns
the original value if value is not a list/string/dict.
Raises EsphomeError if a `defaults:` key is present but is not a mapping.
"""
if isinstance(value, dict) and CONF_DEFAULTS in value:
defaults = value.pop(CONF_DEFAULTS)
if defaults is None:
raise EsphomeError(
f"'{CONF_DEFAULTS}' is empty; "
f"remove the key or give it at least one value"
)
if not isinstance(defaults, dict):
raise EsphomeError(
f"'{CONF_DEFAULTS}' must be a key to value mapping, "
f"got {type(defaults)}"
)
context_vars = {
**value.pop(CONF_DEFAULTS),
**defaults,
**(context_vars or {}),
}
+1
View File
@@ -83,6 +83,7 @@ ISOLATED_COMPONENTS = {
"animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display",
"cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host",
"esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged",
"ecocomfort2": "Needs esp32_ble with io_capability: none and sc_bond, which a merged esp32_ble: block overrides",
"ethernet": "Defines ethernet: which conflicts with wifi: used by most components",
"ethernet_info": "Related to ethernet component which conflicts with wifi",
"gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas",
@@ -10,12 +10,16 @@ from esphome.components.bq25186 import (
CONF_FAST_CHARGE_CURRENT,
CONF_MASK_TS_INTERRUPT,
CONF_PUSH_BUTTON_LONG_PRESS_ACTION,
CONF_WATCHDOG_15S_ENABLE,
CONFIG_SCHEMA,
REG_ICHG_CTRL,
REG_MASK_ID,
REG_SHIP_RST,
REG_VBAT_CTRL,
_check_watchdog,
pack_registers,
)
from esphome.const import CONF_UPDATE_INTERVAL, CONF_WATCHDOG
from esphome.types import ConfigType
# Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the
@@ -85,3 +89,49 @@ def test_masked_registers_only_carry_configurable_bits() -> None:
def test_values_between_steps_are_rejected(key: str, value: str) -> None:
with pytest.raises(cv.Invalid):
BQ25186_CONFIG_SCHEMA({key: value})
def _watchdog_config(interval: str, options: ConfigType) -> ConfigType:
config = BQ25186_CONFIG_SCHEMA(options)
config[CONF_UPDATE_INTERVAL] = cv.update_interval(interval)
return config
@pytest.mark.parametrize(
"interval, options",
[
("60s", {CONF_WATCHDOG: "160s_software_reset"}),
("60s", {CONF_WATCHDOG: "160s_hardware_reset"}),
("30s", {CONF_WATCHDOG: "40s_hardware_reset"}),
("60s", {CONF_WATCHDOG: "disabled"}),
("10s", {CONF_WATCHDOG_15S_ENABLE: True}),
],
)
def test_update_interval_within_watchdog_passes(
interval: str, options: ConfigType
) -> None:
_check_watchdog(_watchdog_config(interval, options))
@pytest.mark.parametrize(
"interval, options",
[
("60s", {CONF_WATCHDOG: "40s_hardware_reset"}),
("40s", {CONF_WATCHDOG: "40s_hardware_reset"}),
("60s", {CONF_WATCHDOG_15S_ENABLE: True}),
("20s", {CONF_WATCHDOG: "160s_software_reset", CONF_WATCHDOG_15S_ENABLE: True}),
("never", {CONF_WATCHDOG: "160s_software_reset"}),
],
)
def test_update_interval_past_watchdog_is_rejected(
interval: str, options: ConfigType
) -> None:
with pytest.raises(cv.Invalid, match=CONF_UPDATE_INTERVAL):
_check_watchdog(_watchdog_config(interval, options))
def test_unconfigured_chip_keeps_its_power_on_watchdog() -> None:
config = {"configure_on_boot": False, "update_interval": "160s"}
with pytest.raises(cv.Invalid, match="shorter than 160 s"):
CONFIG_SCHEMA(config)
CONFIG_SCHEMA({**config, "update_interval": "60s"})
@@ -0,0 +1,43 @@
"""Tests for the sensor value list filter codegen."""
from collections.abc import Callable
import re
def test_value_list_filters_share_progmem_tables(
generate_main: Callable[[str], str],
) -> None:
"""Identical value lists share one PROGMEM table; the NaN throttle stays array free."""
main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml")
tables = re.findall(
r"static constexpr TemplatableFn<float> (\w+)\[\] PROGMEM", main_cpp
)
# Every table ends with an empty entry so the filters store no count.
assert main_cpp.count("TemplatableFn<float>()}") == len(tables)
assert (
len(tables) == 5
) # single, shared list, lambda, two anchored stateful lambdas
single, shared, lam = tables[:3]
assert f"sensor::FilterOutValueFilter({single});" in main_cpp
assert main_cpp.count(f"sensor::FilterOutValueFilter({shared});") == 2
assert f"sensor::FilterOutValueFilter({lam});" in main_cpp
assert f"sensor::ThrottleWithPriorityFilter(1000, {shared});" in main_cpp
assert "sensor::ThrottleWithPriorityNanFilter(1000);" in main_cpp
assert "return 42.0;" in main_cpp
def test_value_list_lambda_tables_are_not_shared(
generate_main: Callable[[str], str],
) -> None:
"""An anchored lambda may keep static state, so each filter keeps its own table."""
main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml")
tables = re.findall(
r"static constexpr TemplatableFn<float> (\w+)\[\] PROGMEM = (.*?)TemplatableFn<float>\(\)\}",
main_cpp,
re.DOTALL,
)
stateful = [name for name, body in tables if "static float n" in body]
assert len(stateful) == 2
assert stateful[0] != stateful[1]
@@ -0,0 +1,51 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: fo_single
lambda: return 1.0;
filters:
- filter_out: 85.0
- platform: template
id: fo_list_a
lambda: return 1.0;
filters:
- filter_out: [0.0, 85.0]
- platform: template
id: fo_list_b
lambda: return 1.0;
filters:
- filter_out: [0.0, 85.0]
- platform: template
id: fo_lambda
lambda: return 1.0;
filters:
- filter_out: !lambda return 42.0;
- platform: template
id: twp
lambda: return 1.0;
filters:
- throttle_with_priority:
timeout: 1s
value: [0.0, 85.0]
- platform: template
id: twp_nan
lambda: return 1.0;
filters:
- throttle_with_priority: 1s
- platform: template
id: fo_stateful_a
lambda: return 1.0;
filters:
- filter_out: &stateful !lambda |-
static float n = 0;
return ++n;
- platform: template
id: fo_stateful_b
lambda: return 1.0;
filters:
- filter_out: *stateful
@@ -0,0 +1,25 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: chip
name: Temperature
number:
- platform: tmp102
tmp102_id: chip
temperature_high:
name: High One
- platform: tmp102
tmp102_id: chip
temperature_low:
name: Low Two
@@ -0,0 +1,24 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: chip
name: Temperature
number:
- platform: tmp102
tmp102_id: chip
temperature_high:
name: High
temperature_low:
name: Low
restore_value: false
@@ -0,0 +1,25 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: chip
name: Temperature
extended_mode: true
temperature_high: 140
temperature_low: 120
number:
- platform: tmp102
temperature_high:
name: High
temperature_low:
name: Low
@@ -0,0 +1,22 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: chip
name: Temperature
number:
- platform: tmp102
tmp102_id: chip
temperature_high:
name: High
initial_value: 30
@@ -58,6 +58,54 @@ def test_duplicate_alert_entities_are_rejected(
)
def test_threshold_numbers_enable_configuration_once(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = generate_main(component_config_path("numbers.yaml"))
assert source.count("set_configuration(") == 1
assert "set_configuration(128, 20480, 19200)" in source
assert source.count("set_limit_control(") == 2
assert "tmp102::TMP102_LIMIT_HIGH" in source
assert "tmp102::TMP102_LIMIT_LOW" in source
assert "set_restore_value(false)" in source
assert "set_max_value(127.9375f)" in source
defines = " ".join(define.name for define in CORE.defines)
assert "USE_TMP102_NUMBER" in defines
assert "USE_TMP102_CONFIGURE" in defines
def test_threshold_numbers_use_sensor_thresholds(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = generate_main(component_config_path("numbers_inherit.yaml"))
assert source.count("set_configuration(") == 1
assert "set_configuration(144, 17920, 15360)" in source
assert "set_max_value(150.0f)" in source
def test_threshold_number_initial_value_is_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
CORE.config_path = component_config_path("numbers_initial_value.yaml")
assert read_config({}) is None
assert "[initial_value] is an invalid option" in capsys.readouterr().out
def test_duplicate_numbers_are_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
CORE.config_path = component_config_path("duplicate_numbers.yaml")
assert read_config({}) is None
assert (
"Only one TMP102 number may reference each temperature sensor"
in capsys.readouterr().out
)
@pytest.mark.parametrize(
("extended", "high", "low"),
[
@@ -117,5 +165,12 @@ def test_precomputed_register_values() -> None:
assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0
def test_threshold_encoding_rounds_half_steps_away_from_zero() -> None:
assert component.encode_temperature(30.03125, False) == 481 << 4
assert component.encode_temperature(-30.03125, False) == (-481 << 4) & 0xFFFF
assert component.encode_temperature(30.03125, True) == 481 << 3
assert component.encode_temperature(-30.03125, True) == (-481 << 3) & 0xFFFF
def test_defaults_match_chip_reset() -> None:
assert component.build_configuration({}) == 0x0080
+74
View File
@@ -23,3 +23,77 @@ bq25186:
address: 0x6B
configure_on_boot: true
fast_charge_current: 20mA
watchdog: 160s_software_reset
update_interval: 30s
binary_sensor:
- platform: bq25186
bq25186_id: bq25186_charger
ts:
name: TS Open
charging:
name: Charging
charge_done:
name: Charge Done
ilim:
name: ILIM Active
vdppm:
name: VDPPM Active
vindpm:
name: VINDPM Active
thermreg:
name: Thermal Regulation Active
power_good:
name: VIN Power Good
vin_ovp:
name: VIN OVP Active
battery_uvlo:
name: Battery UVLO Active
safety_timer_flag:
name: Safety Timer Flag
wake1_flag:
name: Wake 1 Flag
wake2_flag:
name: Wake 2 Flag
ts_flag:
name: TS Flag
ilim_flag:
name: ILIM Flag
vdppm_flag:
name: VDPPM Flag
vindpm_flag:
name: VINDPM Flag
thermreg_flag:
name: Thermal Regulation Flag
vin_ovp_flag:
name: VIN OVP Flag
battery_uvlo_flag:
name: Battery UVLO Flag
battery_ocp_flag:
name: Battery OCP Flag
text_sensor:
- platform: bq25186
bq25186_id: bq25186_charger
charge_status:
name: Charge Status
ts_status:
name: TS Status
button:
- platform: bq25186
bq25186_id: bq25186_charger
software_reset:
name: Software Reset
shutdown:
name: Shutdown
ship_mode:
name: Ship Mode
hardware_reset:
name: Hardware Reset
switch:
- platform: bq25186
bq25186_id: bq25186_boot
pg_gpo:
name: PG GPO
+59
View File
@@ -0,0 +1,59 @@
bq76972:
id: bq76972_id
address: 0x08
crc_enabled: true
reg_disabled: true
update_interval: 10s
stack_voltage:
name: "Pack Total Voltage"
internal_temperature:
name: "Temperature - Internal"
ts1_temperature:
name: "Temperature - Pin TS1"
ts2_temperature:
name: "Temperature - Pin TS2"
ts3_temperature:
name: "Temperature - Pin TS3"
hdq_temperature:
name: "Temperature - Pin HDQ"
cell_1_voltage:
name: "Cell 1 Voltage"
cell_2_voltage:
name: "Cell 2 Voltage"
cell_3_voltage:
name: "Cell 3 Voltage"
cell_4_voltage:
name: "Cell 4 Voltage"
cell_5_voltage:
name: "Cell 5 Voltage"
cell_6_voltage:
name: "Cell 6 Voltage"
cell_7_voltage:
name: "Cell 7 Voltage"
cell_8_voltage:
name: "Cell 8 Voltage"
cell_9_voltage:
name: "Cell 9 Voltage"
cell_10_voltage:
name: "Cell 10 Voltage"
cell_11_voltage:
name: "Cell 11 Voltage"
cell_12_voltage:
name: "Cell 12 Voltage"
cell_13_voltage:
name: "Cell 13 Voltage"
cell_14_voltage:
name: "Cell 14 Voltage"
cell_15_voltage:
name: "Cell 15 Voltage"
cell_16_voltage:
name: "Cell 16 Voltage"
button:
- platform: template
name: "Program BQ I2C Address"
on_press:
- bq76972.program_i2c_address:
id: bq76972_id
address: 0x09
- bq76972.program_i2c_address: !lambda "return 0x08;"
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
bq76972: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
bq76972: !include common.yaml
@@ -5,7 +5,7 @@ power_management:
min_frequency: ${esp32_power_management_min_frequency}
enable_light_sleep: true
idle_time_before_sleep: 3
power_down_flash: true
power_down_flash: ${esp32_power_management_power_down_flash}
power_down_peripherals: ${esp32_power_management_power_down_peripherals}
profiling: true
trace: true
@@ -0,0 +1,8 @@
substitutions:
esp32_power_management_max_frequency: 160MHZ
esp32_power_management_min_frequency: 40MHZ
esp32_power_management_power_down_peripherals: "false"
esp32_power_management_power_down_flash: "true"
packages:
esp32_power_management: !include common.yaml
@@ -2,6 +2,7 @@ substitutions:
esp32_power_management_max_frequency: 160MHZ
esp32_power_management_min_frequency: 40MHZ
esp32_power_management_power_down_peripherals: "false"
esp32_power_management_power_down_flash: "false"
packages:
esp32_power_management: !include common.yaml
@@ -2,6 +2,7 @@ substitutions:
esp32_power_management_max_frequency: 96MHZ
esp32_power_management_min_frequency: 32MHZ
esp32_power_management_power_down_peripherals: "true"
esp32_power_management_power_down_flash: "false"
packages:
esp32_power_management: !include common.yaml
@@ -2,6 +2,7 @@ substitutions:
esp32_power_management_max_frequency: 160MHZ
esp32_power_management_min_frequency: 40MHZ
esp32_power_management_power_down_peripherals: "false"
esp32_power_management_power_down_flash: "false"
packages:
esp32_power_management: !include common.yaml
+15
View File
@@ -0,0 +1,15 @@
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
from esphome.components.remote_base import request_protocol
# The harness walks the component manifest, not the climate platform, so declare its auto-load here
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# The AEHA source is compiled only behind its define; climate.py requests it in the real build.
request_protocol("aeha")
manifest.to_code = to_code_testing
+5
View File
@@ -0,0 +1,5 @@
climate:
- platform: friedrich
name: Friedrich Climate
transmitter_id: xmitr
receiver_id: rcvr
@@ -0,0 +1,215 @@
#include <gtest/gtest.h>
#include <functional>
#include "esphome/components/friedrich/friedrich.h"
#include "esphome/components/remote_base/aeha_protocol.h"
#include "esphome/core/helpers.h"
namespace esphome::friedrich::testing {
using remote_base::AEHAData;
using remote_base::AEHAProtocol;
// Records the last transmission instead of sending it
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
int sends{0};
remote_base::RawTimings last;
protected:
void send_internal(uint32_t, uint32_t) override {
this->sends++;
this->last = this->temp_.get_data();
}
};
class TestFriedrich : public FriedrichClimate {
public:
using FriedrichClimate::on_receive;
using FriedrichClimate::setup;
using FriedrichClimate::traits;
using FriedrichClimate::transmit_state;
};
constexpr uint16_t ADDRESS = 0x28C6;
constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH;
// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan
constexpr uint8_t TEMP_72_CODE = 0x06;
struct Fixture {
TestFriedrich sut;
CapturingTransmitter transmitter;
Fixture() { this->sut.set_transmitter(&this->transmitter); }
// The last transmission decoded, or an empty frame if it could not be decoded
AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); }
bool receive(const AEHAData &frame) {
remote_base::RemoteTransmitData tx;
AEHAProtocol().encode(&tx, frame);
return this->sut.on_receive(rx_of(tx.get_data()));
}
static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) {
return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
}
};
// Independent implementation of the 14 byte frame checksum
static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) {
AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}};
uint8_t sum = 0;
for (int i = 6; i < 13; i++)
sum += reverse_bits(f.data[i]);
f.data[13] = reverse_bits(static_cast<uint8_t>(208 - sum));
return f;
}
static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; }
TEST(FriedrichTests, TraitsReportFahrenheit) {
Fixture f;
auto traits = f.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f);
}
TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_OFF;
f.sut.transmit_state();
ASSERT_EQ(f.transmitter.sends, 1);
EXPECT_EQ(f.sent(), off_frame());
}
TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 72;
f.sut.fan_mode = climate::CLIMATE_FAN_AUTO;
f.sut.swing_mode = climate::CLIMATE_SWING_OFF;
f.sut.transmit_state();
EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80));
}
TEST(FriedrichTests, OddTargetStepsUpToEven) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 71;
f.sut.transmit_state();
auto data = f.sent();
ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH);
EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80);
EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent
}
// Codes captured from the remote
TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) {
static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E,
0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07};
for (size_t i = 0; i < sizeof(CODES); i++) {
const auto fahrenheit = static_cast<uint8_t>(60 + i * 2);
SCOPED_TRACE(fahrenheit);
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_HEAT;
f.sut.target_temperature = fahrenheit;
f.sut.transmit_state();
EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20));
Fixture g;
EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20)));
EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit);
}
}
TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 60;
f.sut.transmit_state();
EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f);
auto data = f.sent();
ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH);
EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F
f.sut.mode = climate::CLIMATE_MODE_HEAT;
f.sut.target_temperature = 60;
f.sut.transmit_state();
EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f);
}
TEST(FriedrichTests, ReceivesStateFrame) {
Fixture f;
EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80)));
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT);
EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f);
EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH);
}
TEST(FriedrichTests, ReceivesOffFrame) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
EXPECT_TRUE(f.receive(off_frame()));
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF);
}
TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 80;
EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f);
}
TEST(FriedrichTests, IgnoresCorruptedFrames) {
struct Case {
const char *name;
AEHAData frame;
std::function<void(AEHAData &)> corrupt;
};
const Case cases[] = {
{"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }},
{"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }},
{"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }},
{"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }},
};
for (const auto &c : cases) {
SCOPED_TRACE(c.name);
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
AEHAData frame = c.frame;
c.corrupt(frame);
EXPECT_FALSE(f.receive(frame));
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL);
}
}
TEST(FriedrichTests, OwnTransmissionIsAccepted) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY;
f.sut.target_temperature = 76;
f.sut.fan_mode = climate::CLIMATE_FAN_LOW;
f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL;
f.sut.transmit_state();
Fixture g;
EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last)));
EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY);
EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f);
EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) {
Fixture f;
f.sut.setup();
EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f);
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::friedrich::testing
@@ -0,0 +1,4 @@
packages:
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
friedrich: !include common.yaml
@@ -0,0 +1,4 @@
packages:
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
friedrich: !include common.yaml
+29
View File
@@ -13,3 +13,32 @@ sensor:
shunt_voltage:
name: INA3221 Channel 1 Shunt Voltage
update_interval: 15s
- platform: ina3221
i2c_id: i2c_bus
address: 0x41
continuous_mode: false
adc_averaging: 4
adc_time:
bus_voltage: 2116us
shunt_voltage: 1.1ms
channel_1:
shunt_resistance: 0.05 ohm
current:
name: INA3221 B Channel 1 Current
warning_current_limit: 1A
critical_current_limit: 2A
channel_2:
shunt_resistance: 0.05 ohm
bus_voltage:
name: INA3221 B Channel 2 Bus Voltage
channel_3:
shunt_resistance: 0.05 ohm
power:
name: INA3221 B Channel 3 Power
sum_shunt_voltage:
name: INA3221 B Sum Shunt Voltage
sum_current:
name: INA3221 B Sum Current
sum_power:
name: INA3221 B Sum Power
update_interval: 15s
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
ina3221: !include common.yaml
@@ -1,4 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
ina3221: !include common.yaml
@@ -1,4 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
ina3221: !include common.yaml
+28
View File
@@ -0,0 +1,28 @@
ld2410s:
id: ld2410s_bare
uart_id: uart_bus
binary_sensor:
- platform: ld2410s
ld2410s_id: ld2410s_bare
has_target:
name: Has Target
# Entities of other platforms must not pull the parameter code into a bare hub
number:
- platform: template
name: Unrelated Number
optimistic: true
min_value: 0
max_value: 10
step: 1
select:
- platform: template
name: Unrelated Select
optimistic: true
options: ["a", "b"]
text_sensor:
- platform: template
name: Unrelated Text
+28
View File
@@ -5,3 +5,31 @@ binary_sensor:
- platform: ld2410s
has_target:
name: Presence
sensor:
- platform: ld2410s
distance:
name: Distance
text_sensor:
- platform: ld2410s
version:
name: Firmware
number:
- platform: ld2410s
max_distance:
name: Max Distance
min_distance:
name: Min Distance
no_delay:
name: No Delay
status_reporting_frequency:
name: Status Reporting Frequency
distance_reporting_frequency:
name: Distance Reporting Frequency
select:
- platform: ld2410s
response_speed:
name: Response Speed
@@ -0,0 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
ld2410s: !include common-bare.yaml
@@ -101,6 +101,9 @@ class TestableRuntimeImage : public RuntimeImage {
explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE)
: RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {}
TestableRuntimeImage(ImageFormat format, image::ImageType type, bool is_big_endian, int fixed_width, int fixed_height)
: RuntimeImage(format, type, image::TRANSPARENCY_OPAQUE, nullptr, is_big_endian, fixed_width, fixed_height) {}
ImageDecoder *decoder() { return this->decoder_.get(); }
};
@@ -373,6 +376,87 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
EXPECT_EQ(img.decoder(), first);
EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels";
}
// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each
// channel is checked within one step; the byte order is still exact
static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) {
EXPECT_NEAR(actual >> 11, expected >> 11, 1) << order << " pixel " << pixel << " red";
EXPECT_NEAR((actual >> 5) & 0x3F, (expected >> 5) & 0x3F, 1) << order << " pixel " << pixel << " green";
EXPECT_NEAR(actual & 0x1F, expected & 0x1F, 1) << order << " pixel " << pixel << " blue";
}
TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
// Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those
// pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source
// pixel (2, 0), which is word 0x3803 here.
static const uint16_t EXPECTED[4 * 4] = {
0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F,
0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17,
};
TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4);
ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_EQ(rgb565_little_endian.get_width(), 4);
ASSERT_EQ(rgb565_little_endian.get_height(), 4);
ASSERT_EQ(rgb565_big_endian.get_width(), 4);
ASSERT_EQ(rgb565_big_endian.get_height(), 4);
const uint8_t *little = rgb565_little_endian.get_data_start();
const uint8_t *big = rgb565_big_endian.get_data_start();
for (size_t i = 0; i < 4 * 4; i++) {
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i);
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i);
}
}
// Target pixel index each source pixel paints when `source` pixels map onto `target`, the way
// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start
static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; }
// The RGB565 word at (x, y) of a decoded image, in its own byte order
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
return p[0] | (p[1] << 8);
}
// Every scaled direct decode must equal the unscaled one sampled through the boundary table,
// for shrinking, growing and a ratio that is not a whole number
TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) {
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
for (int size : {4, 6, 16}) {
TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_EQ(scaled.get_width(), size);
ASSERT_EQ(scaled.get_height(), size);
for (int n = 0; n < 8; n++) {
for (int m = 0; m < 8; m++) {
for (int y = scaled_boundary(m, 8, size); y < scaled_boundary(m + 1, 8, size); y++) {
for (int x = scaled_boundary(n, 8, size); x < scaled_boundary(n + 1, 8, size); x++) {
EXPECT_EQ(rgb565_word(scaled, x, y), rgb565_word(full, n, m))
<< size << "x" << size << " pixel " << x << "," << y;
}
}
}
}
}
}
// A second decode into the same image reuses the boundary tables and gives the same pixels
TEST(RuntimeImageDecoder, JpegDirectRgb565RepeatsWithReusedTables) {
TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, 6, 6);
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
std::vector<uint8_t> first_pixels = pixel_bytes(img);
ImageDecoder *first = img.decoder();
ASSERT_NE(first, nullptr);
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
EXPECT_EQ(img.decoder(), first);
EXPECT_EQ(pixel_bytes(img), first_pixels);
}
#endif // USE_RUNTIME_IMAGE_JPEG
TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) {
@@ -0,0 +1,129 @@
sensor:
- platform: selec_meter
modbus_id: modbus_bus
model: em4m
byte_order: little_endian
phase_a:
voltage:
name: SelecEM4M Voltage L1
current:
name: SelecEM4M Current L1
active_power:
name: SelecEM4M Active Power L1
reactive_power:
name: SelecEM4M Reactive Power L1
apparent_power:
name: SelecEM4M Apparent Power L1
power_factor:
name: SelecEM4M Power Factor L1
import_active_energy:
name: SelecEM4M Import Active Energy L1
export_active_energy:
name: SelecEM4M Export Active Energy L1
import_reactive_energy:
name: SelecEM4M Import Reactive Energy L1
export_reactive_energy:
name: SelecEM4M Export Reactive Energy L1
apparent_energy:
name: SelecEM4M Apparent Energy L1
phase_b:
voltage:
name: SelecEM4M Voltage L2
current:
name: SelecEM4M Current L2
active_power:
name: SelecEM4M Active Power L2
reactive_power:
name: SelecEM4M Reactive Power L2
apparent_power:
name: SelecEM4M Apparent Power L2
power_factor:
name: SelecEM4M Power Factor L2
import_active_energy:
name: SelecEM4M Import Active Energy L2
export_active_energy:
name: SelecEM4M Export Active Energy L2
import_reactive_energy:
name: SelecEM4M Import Reactive Energy L2
export_reactive_energy:
name: SelecEM4M Export Reactive Energy L2
apparent_energy:
name: SelecEM4M Apparent Energy L2
phase_c:
voltage:
name: SelecEM4M Voltage L3
current:
name: SelecEM4M Current L3
active_power:
name: SelecEM4M Active Power L3
reactive_power:
name: SelecEM4M Reactive Power L3
apparent_power:
name: SelecEM4M Apparent Power L3
power_factor:
name: SelecEM4M Power Factor L3
import_active_energy:
name: SelecEM4M Import Active Energy L3
export_active_energy:
name: SelecEM4M Export Active Energy L3
import_reactive_energy:
name: SelecEM4M Import Reactive Energy L3
export_reactive_energy:
name: SelecEM4M Export Reactive Energy L3
apparent_energy:
name: SelecEM4M Apparent Energy L3
voltage_l12:
name: SelecEM4M Voltage L12
voltage_l23:
name: SelecEM4M Voltage L23
voltage_l31:
name: SelecEM4M Voltage L31
average_voltage_ll:
name: SelecEM4M Average Voltage LL
active_power:
name: SelecEM4M Active Power
reactive_power:
name: SelecEM4M Reactive Power
apparent_power:
name: SelecEM4M Apparent Power
voltage:
name: SelecEM4M Average Voltage
current:
name: SelecEM4M Average Current
power_factor:
name: SelecEM4M Average Power Factor
frequency:
name: SelecEM4M Frequency
import_active_energy:
name: SelecEM4M Total Import Active Energy
export_active_energy:
name: SelecEM4M Total Export Active Energy
import_reactive_energy:
name: SelecEM4M Total Import Reactive Energy
export_reactive_energy:
name: SelecEM4M Total Export Reactive Energy
net_active_energy_mains:
name: SelecEM4M Net Active Energy Mains
net_reactive_energy_mains:
name: SelecEM4M Net Reactive Energy Mains
net_apparent_energy_mains:
name: SelecEM4M Net Apparent Energy Mains
net_active_energy_dg:
name: SelecEM4M Net Active Energy DG
net_reactive_energy_dg:
name: SelecEM4M Net Reactive Energy DG
net_apparent_energy_dg:
name: SelecEM4M Net Apparent Energy DG
maximum_demand_active_power:
name: SelecEM4M Maximum Demand Active Power
disabled_by_default: true
maximum_demand_reactive_power:
name: SelecEM4M Maximum Demand Reactive Power
disabled_by_default: true
maximum_demand_apparent_power:
name: SelecEM4M Maximum Demand Apparent Power
disabled_by_default: true
serial_number:
name: SelecEM4M Serial Number
dg_sensing:
name: SelecEM4M DG Sensing
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
flow_control_pin: GPIO26
packages:
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
selec_meter: !include common-em4m.yaml
@@ -0,0 +1,8 @@
substitutions:
tx_pin: GPIO0
rx_pin: GPIO2
flow_control_pin: GPIO15
packages:
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
selec_meter: !include common-em4m.yaml
@@ -4,6 +4,5 @@ substitutions:
flow_control_pin: GPIO26
packages:
selec_meter: !include common.yaml
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
<<: !include common.yaml
@@ -4,6 +4,5 @@ substitutions:
flow_control_pin: GPIO15
packages:
selec_meter: !include common.yaml
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml
<<: !include common.yaml
@@ -4,6 +4,5 @@ substitutions:
flow_control_pin: GPIO6
packages:
selec_meter: !include common.yaml
modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml
<<: !include common.yaml

Some files were not shown because too many files have changed in this diff Show More