Compare commits

...
Author SHA1 Message Date
J. Nick Koston eb6af50a89 Merge branch 'binary-sensor-lazy-integration-imports' into sensor-lazy-integration-imports 2026-10-13 11:23:16 -10:00
J. Nick Koston fa070bf340 Merge branch 'component-import-benchmarks' into binary-sensor-lazy-integration-imports 2026-10-13 11:23:16 -10:00
J. Nick Koston a31ce9bd9e Drop the components package too so the re-import is real 2026-10-13 11:23:13 -10:00
J. Nick Koston 26bede7a48 Merge branch 'binary-sensor-lazy-integration-imports' into sensor-lazy-integration-imports 2026-10-13 11:19:26 -10:00
J. Nick Koston fd7fcbad0f Merge branch 'component-import-benchmarks' into binary-sensor-lazy-integration-imports 2026-10-13 11:19:26 -10:00
J. Nick Koston 5860a392bd Merge remote-tracking branch 'origin/component-import-benchmarks' into component-import-benchmarks
# Conflicts:
#	tests/benchmarks/python/test_component_import_bench.py
2026-10-13 11:19:23 -10:00
J. Nick Koston 04c2bf1286 Merge branch 'binary-sensor-lazy-integration-imports' into sensor-lazy-integration-imports
# Conflicts:
#	esphome/components/zigbee/const_zephyr.py
2026-10-13 11:18:27 -10:00
J. Nick Koston 55487971c3 Point the const_zephyr comment at esphome.const 2026-10-13 11:16:29 -10:00
J. Nick Koston 82c535e0a7 Merge branch 'component-import-benchmarks' into binary-sensor-lazy-integration-imports 2026-10-13 11:16:28 -10:00
J. Nick Koston de6991144e Time only the component import and put the original modules back afterwards 2026-10-13 11:16:05 -10:00
J. Nick Koston af1285f006 Put the session's esphome modules back after each import benchmark 2026-10-13 11:08:28 -10:00
J. Nick Koston 549761a574 Merge branch 'binary-sensor-lazy-integration-imports' into sensor-lazy-integration-imports 2026-10-13 10:25:49 -10:00
J. Nick Koston 2fa7775fe7 Merge branch 'component-import-benchmarks' into binary-sensor-lazy-integration-imports 2026-10-13 10:25:49 -10:00
J. Nick Koston 926f2561b4 [core] Add CodSpeed benchmarks for importing entity components 2026-10-13 10:24:07 -10:00
J. Nick Koston c4544e6a22 Merge branch 'binary-sensor-lazy-integration-imports' into sensor-lazy-integration-imports
# Conflicts:
#	esphome/components/sensor/__init__.py
#	esphome/components/zigbee/__init__.py
#	esphome/components/zigbee/const_zephyr.py
#	esphome/core/entity_helpers.py
2026-10-13 10:17:57 -10:00
J. Nick Koston f24dba9de8 Merge remote-tracking branch 'origin/dev' into binary-sensor-lazy-integration-imports
# Conflicts:
#	esphome/components/binary_sensor/__init__.py
#	esphome/components/web_server/__init__.py
#	esphome/components/zigbee/__init__.py
#	esphome/components/zigbee/const.py
#	script/ci-custom.py
#	tests/unit_tests/core/test_entity_helpers.py
2026-10-13 10:14:13 -10:00
918b7d2553 [script] Keep the running script.wait item out of the queue while its chain runs (#20570)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 09:31:19 -10:00
rwrozelleandClaude Opus 5.5 417ab4d2e9 [openthread] Add core_options for OpenThread compile-time options (#20595)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:00:45 -04:00
Jonathan Swoboda 0821c84b7a [core] Let a nested repeat shadow the outer iteration variable (#20634) 2026-10-13 14:35:24 -04:00
Clyde Stubbs a827058928 [lvgl] Change deprecated calls to current version (#20620) 2026-10-13 14:27:50 -04:00
J. Nick Koston 3063b76210 [haier_tundra] Build the IR frame on the stack (#20635) 2026-10-13 14:25:36 -04:00
Remco van EssenandClaude Opus 5.5 55261ae7e5 [tmc2209] Add driver diagnostics and stall detection (#20618)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:17:25 -10:00
cociwebandJ. Nick Koston 1d8423edd8 [es8388] Add initial option configuration for DAC output and ADC input mic (#16342)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:14:43 -10:00
n-IA-haneandn-IA-hane d27ec8ec49 [voice_assistant] Make TTS playback start timeout configurable (#18707)
Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
2026-10-13 08:13:26 -10:00
Ivan KruglovandJ. Nick Koston 5acbd44067 [pcf8574] Add reset option to preserve outputs across MCU reboots (#20589)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:12:28 -10:00
40271fb597 [haier_tundra] New Climate IR component (#20469)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:10:36 -10:00
95766ed3ad [icm20689] Add ICM-20689 6-axis accelerometer/gyroscope (SPI) (#19234)
Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 08:07:32 -10:00
4a80d785a8 [filter_lifetime] Add component with sensor/lambda support for is_on … (#13914)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 07:52:46 -10:00
J. Nick Koston c1c1d2b2ae [core] Add probe_ota_key, a Noise handshake with the device that sends no image (#20601) 2026-10-13 07:28:32 -10:00
Mat931andJonathan Swoboda 29a668af04 [esp32_camera] Fix compile warning (#20628)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-13 14:01:17 +00:00
J. Nick Koston fd541bca2c [core] Document constexpr constants, per-model schemas and isolated build tests in AGENTS.md (#20617) 2026-10-13 08:20:32 -04:00
J. Nick Koston 192ae31e45 [tests] Turn off the Hypothesis deadline for the MAC address tests (#20614) 2026-10-13 08:19:10 -04:00
Frédéric MetrichandClaude Opus 5.5 77c9fa2fa4 [emontx] Read the emonTx firmware version (#20619)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:43:02 -10:00
guillempagesandJ. Nick Koston 25843c5242 [image] Implement RGB111 format (#20114)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 22:16:44 -10:00
7881dfc5d8 [deep_sleep] Reject wakeup_pin configs that fail after validation (#18616)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:29:42 +11:00
pre-commit-ci-lite[bot] 82c3e0db96 apply automatic formatting fixes 2026-09-14 15:57:18 +00:00
J. Nick Koston e207138557 Merge remote-tracking branch 'origin/dev' into binary-sensor-lazy-integration-imports
# Conflicts:
#	esphome/components/web_server/__init__.py
2026-09-14 10:54:52 -05:00
J. Nick Koston 11af5a1e78 Use the shared integration setup helper 2026-08-13 21:21:56 -05:00
J. Nick Koston a04dca7823 [sensor] Defer integration imports out of module scope 2026-08-13 21:21:56 -05:00
J. Nick Koston edeaded161 Consolidate zigbee schema factory and shared integration setup 2026-08-13 21:21:17 -05:00
J. Nick Koston 09a3b1a3d1 Address review: stable schema anchors, const.py home, validator hardening 2026-08-13 21:21:17 -05:00
J. Nick Koston 10d232dc40 Cover the report deprecation warning and lazy validator paths 2026-08-13 21:21:17 -05:00
J. Nick Koston 663f462877 [binary_sensor] Defer integration imports out of module scope 2026-08-13 21:20:59 -05:00
117 changed files with 5218 additions and 387 deletions
+12
View File
@@ -144,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting
* **C++ Preprocessor Directives:**
* **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums.
* **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope
and inside functions; `static const` is only for a value computed at run time. A function-local
`static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too.
* **Use `#define` only for:**
- Conditional compilation (`#ifdef`, `#ifndef`)
- Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`)
@@ -217,6 +220,9 @@ file does, and it is the authority when they disagree. The most useful starting
listener base (a second vptr per entity).
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
(`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check
stays inside the `#ifdef`.
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
@@ -567,6 +573,9 @@ file does, and it is the authority when they disagree. The most useful starting
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
* **Configuration Validation:**
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
that rejects keys and fills defaults afterwards.
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
@@ -609,6 +618,9 @@ file does, and it is the authority when they disagree. The most useful starting
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
on. A variant per board for platform-identical code only adds CI time.
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
+5
View File
@@ -212,6 +212,7 @@ esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/filter_lifetime/* @tuct
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/friedrich/* @rwrozelle
@@ -242,6 +243,9 @@ esphome/components/haier/button/* @paveldn
esphome/components/haier/sensor/* @paveldn
esphome/components/haier/switch/* @paveldn
esphome/components/haier/text_sensor/* @paveldn
esphome/components/haier_tundra/* @fauxpark
esphome/components/haier_tundra/button/* @fauxpark
esphome/components/haier_tundra/switch/* @fauxpark
esphome/components/havells_solar/* @sourabhjaiswal
esphome/components/hbridge/fan/* @WeekendWarrior
esphome/components/hbridge/light/* @DotNetDann
@@ -286,6 +290,7 @@ esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/i2s_clockless_led_strip/* @j9brown
esphome/components/iaqcore/* @yozik04
esphome/components/icm20689/* @lboue
esphome/components/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
esphome/components/improv_base/* @esphome/core
+16 -18
View File
@@ -483,12 +483,9 @@ def has_ota() -> bool:
def has_native_ota() -> bool:
"""Check if native API OTA upload is available (``platform: esphome``)."""
if CONF_OTA not in CORE.config:
return False
return any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
for ota_item in CORE.config[CONF_OTA]
)
from esphome.espota2 import esphome_ota_items
return bool(esphome_ota_items(CORE.config))
def has_web_server_ota() -> bool:
@@ -1359,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
return CONF_WEB_SERVER
def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
"""The validated esphome OTA platform block, if configured."""
from esphome.espota2 import esphome_ota_items
return next(iter(esphome_ota_items(config)), None)
def _upload_via_native_api(
config: ConfigType, network_devices: list[str], args: ArgsProtocol
) -> tuple[int, str | None]:
ota_conf: ConfigType = {}
for ota_item in config.get(CONF_OTA, []):
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
ota_conf = ota_item
break
ota_conf = _esphome_ota_conf(config) or {}
from esphome import espota2
from esphome.components.noise import static_encryption_key
@@ -1457,10 +1457,8 @@ def _old_key_notice(config: ConfigType) -> None:
`upload` may send a stale image that still carries the previous key."""
from esphome import espota2
for item in config.get(CONF_OTA, []):
if item.get(CONF_PLATFORM) == CONF_ESPHOME and (
item.get(CONF_ENCRYPTION) or {}
).get(espota2.CONF_OLD_KEY):
for item in espota2.esphome_ota_items(config):
if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY):
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
@@ -1468,12 +1466,12 @@ def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
from esphome import web_server_ota
from esphome.espota2 import esphome_ota_items
from esphome.web_server_helpers import get_web_server_connection
if any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in config.get(CONF_OTA, [])
ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in esphome_ota_items(config)
):
_LOGGER.warning(
"This config has OTA encryption, but the web_server OTA path sends "
+16 -1
View File
@@ -906,6 +906,21 @@ async def while_action_to_code(
return var
def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType:
"""Rename an inherited `iteration` so the inner repeat's own counter can shadow it."""
names = {name for _, name in args}
renamed: TemplateArgsType = []
for type_, name in args:
if name == "iteration":
depth = 1
while f"iteration_{depth}" in names:
depth += 1
name = f"iteration_{depth}"
names.add(name)
renamed.append((type_, name))
return renamed
@register_action(
"repeat",
RepeatAction,
@@ -929,7 +944,7 @@ async def repeat_action_to_code(
actions = await build_action_list(
config[CONF_THEN],
cg.TemplateArguments(cg.uint32, *template_arg.args),
[(cg.uint32, "iteration"), *args],
[(cg.uint32, "iteration"), *_shadow_iteration(args)],
)
for action in actions:
cg.add(var.add_then_action(action))
+11 -13
View File
@@ -3,7 +3,6 @@ from logging import getLogger
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_ON_STATE_CHANGE
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
@@ -29,7 +28,6 @@ from esphome.const import (
CONF_STATE,
CONF_TIMING,
CONF_TRIGGER_ID,
CONF_WEB_SERVER,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_CARBON_MONOXIDE,
@@ -61,10 +59,12 @@ from esphome.const import (
DEVICE_CLASS_VIBRATION,
DEVICE_CLASS_WINDOW,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity_helpers
from esphome.core.entity_helpers import (
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
mqtt_component_class,
queue_entity_register,
setup_device_class,
setup_entity,
@@ -433,14 +433,14 @@ def validate_publish_initial_state(value):
_BINARY_SENSOR_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMPONENT_SCHEMA)
.extend(zigbee.BINARY_SENSOR_SCHEMA)
.extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA)
.extend(
{
cv.GenerateID(): cv.declare_id(BinarySensor),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt.MQTTBinarySensorComponent
mqtt_component_class("MQTTBinarySensorComponent")
),
cv.Exclusive(
CONF_PUBLISH_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
@@ -508,7 +508,7 @@ _BINARY_SENSOR_SCHEMA = (
_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor"))
_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor)
_BINARY_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_binary_sensor"))
def binary_sensor_schema(
@@ -619,14 +619,12 @@ async def setup_binary_sensor_core_(var, config):
CORE.add_job(_build_binary_sensor_automations, var, config)
if mqtt_id := config.get(CONF_MQTT_ID):
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
await entity_helpers.setup_entity_integrations(var, config)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
if "zigbee" in CORE.loaded_integrations:
from esphome.components import zigbee
await zigbee.setup_binary_sensor(var, config)
await zigbee.setup_binary_sensor(var, config)
async def register_binary_sensor(var, config):
+38 -29
View File
@@ -143,6 +143,8 @@ def validate_wakeup_pin(
processed_pins: list[ConfigType] = [{CONF_PIN: value}]
else:
processed_pins = list(value)
if not processed_pins:
raise cv.Invalid("'wakeup_pin' needs at least one entry")
for i, pin_config in enumerate(processed_pins):
# now validate each item
@@ -154,29 +156,34 @@ def validate_wakeup_pin(
def validate_config(config: ConfigType) -> ConfigType:
# right now only BK72XX supports the list format for wakeup pins
if CORE.is_bk72xx:
if CONF_WAKEUP_PIN_MODE in config:
wakeup_pins = config.get(CONF_WAKEUP_PIN, [])
if len(wakeup_pins) > 1:
raise cv.Invalid(
"You need to remove the global wakeup_pin_mode and define it per pin"
)
if wakeup_pins:
if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]:
raise cv.Invalid(
"Specify wakeup_pin_mode either at the top level under deep_sleep "
"or under the pin entry, not both"
)
wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
elif (
isinstance(config.get(CONF_WAKEUP_PIN), list)
and len(config[CONF_WAKEUP_PIN]) > 1
):
wakeup_pins = config.get(CONF_WAKEUP_PIN)
has_pin_mode = CONF_WAKEUP_PIN_MODE in config
if has_pin_mode and wakeup_pins is None:
raise cv.Invalid(
"Your platform does not support providing multiple entries in wakeup_pin"
"'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE]
)
multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1
# wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list
if not CORE.is_bk72xx:
if multiple_pins:
raise cv.Invalid(
"ESP32 does not support multiple entries for 'wakeup_pin'",
path=[CONF_WAKEUP_PIN],
)
elif has_pin_mode:
if multiple_pins:
raise cv.Invalid(
"Remove the global 'wakeup_pin_mode' and define it per pin",
path=[CONF_WAKEUP_PIN_MODE],
)
if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]):
raise cv.Invalid(
"Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' "
"or under the pin entry, not both",
path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE],
)
# Move the global mode into the single pin entry, which to_code() reads
pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
return config
@@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod:
if CORE.is_bk72xx:
max_duration = core.TimePeriod(hours=36)
if value > max_duration:
raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX")
raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX")
elif CORE.using_zephyr:
max_duration = core.TimePeriod(days=49)
if value > max_duration:
raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr")
raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr")
return value
@@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All(
cv.positive_time_period_milliseconds,
_validate_sleep_duration,
),
cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin,
cv.Optional(CONF_WAKEUP_PIN): cv.All(
cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]),
validate_wakeup_pin,
),
cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All(
cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]),
cv.enum(WAKEUP_PIN_MODES, upper=True),
@@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None:
if CONF_SLEEP_DURATION in config:
cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION]))
if CONF_WAKEUP_PIN in config:
pins_as_list = config.get(CONF_WAKEUP_PIN, [])
if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None:
if CORE.is_bk72xx:
cg.add(var.init_wakeup_pins(len(pins_as_list)))
for item in pins_as_list:
cg.add(var.init_wakeup_pins(len(wakeup_pins)))
for item in wakeup_pins:
cg.add(
var.add_wakeup_pin(
await cg.gpio_pin_expression(item[CONF_PIN]),
@@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None:
)
)
else:
pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN])
pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN])
cg.add(var.set_wakeup_pin(pin))
if CONF_WAKEUP_PIN_MODE in config:
cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE]))
+65 -6
View File
@@ -1,19 +1,32 @@
#include "emontx.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include "esphome/components/json/json_util.h"
namespace esphome::emontx {
ESPHOME_LOG_TAG(TAG, "emontx");
#ifdef USE_EMONTX_VERSION
static constexpr uint32_t VERSION_REQUEST_ID = 0;
// The emonTx joins bytes received while a command is pending into that command, so 'v' waits
static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250;
// Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1"
static const char VERSION_PREFIX[] PROGMEM = " - Version:";
#endif
void EmonTx::setup() {
this->buffer_pos_ = 0;
if (!this->lock_on_reboot_)
return;
// The line feed ends any boot noise first
ESP_LOGI(TAG, "Unlocking emonTx after boot");
this->write_byte('\n');
this->send_command("emonunlock");
if (this->lock_on_reboot_) {
// The line feed ends any boot noise first
ESP_LOGI(TAG, "Unlocking emonTx after boot");
this->write_byte('\n');
this->send_command("emonunlock");
}
#ifdef USE_EMONTX_VERSION
if (this->version_text_sensor_ != nullptr)
this->request_version_();
#endif
}
void EmonTx::on_shutdown() {
@@ -48,6 +61,11 @@ void EmonTx::loop() {
uint8_t dropped;
while (this->available() > 0 && this->read_byte(&dropped))
this->discarding_line_ = dropped != '\n';
#ifdef USE_EMONTX_VERSION
// The emonTx may have been flashed, or a request dropped, during the pause
if (this->version_text_sensor_ != nullptr)
this->request_version_();
#endif
}
// Read all available data to prevent UART buffer overflow
@@ -77,6 +95,10 @@ void EmonTx::loop() {
ESP_LOGV(TAG, "Line is JSON, parsing...");
this->parse_json_(this->buffer_.data(), len);
}
#ifdef USE_EMONTX_VERSION
if (this->version_text_sensor_ != nullptr)
this->parse_version_line_(this->buffer_.data(), len);
#endif
}
} else if (received == '\r' || this->discarding_line_) {
// Ignore CR, and the rest of a line that overflowed or was interrupted by a pause
@@ -102,6 +124,16 @@ void EmonTx::parse_json_(const char *data, size_t len) {
}
}
#endif
#ifdef USE_EMONTX_VERSION
auto msg = root["MSG"];
if (this->version_text_sensor_ != nullptr && msg.is<uint32_t>()) {
// MSG restarts at 1 when the emonTx reboots, as it does after a firmware update
uint32_t msg_number = msg;
if (msg_number <= this->last_msg_number_)
this->request_version_();
this->last_msg_number_ = msg_number;
}
#endif
this->json_callbacks_.call(root, StringRef(data, len));
return true;
@@ -112,6 +144,30 @@ void EmonTx::parse_json_(const char *data, size_t len) {
}
}
#ifdef USE_EMONTX_VERSION
// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_()
void EmonTx::request_version_() {
if (this->is_paused())
return;
this->write_byte('\n');
this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); });
}
void EmonTx::parse_version_line_(const char *line, size_t len) {
constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1;
if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0)
return;
const char *version = line + prefix_len;
const char *end = line + len;
while (version < end && *version == ' ')
version++;
if (version == end)
return;
ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast<int>(end - version), version);
this->version_text_sensor_->publish_state(version, end - version);
}
#endif
/**
* @brief Logs the EmonTx component configuration details.
*/
@@ -127,6 +183,9 @@ void EmonTx::dump_config() {
#else
ESP_LOGCONFIG(TAG, " Sensor support: DISABLED");
#endif
#ifdef USE_EMONTX_VERSION
LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_);
#endif
}
/**
+13
View File
@@ -13,6 +13,9 @@
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_EMONTX_VERSION
#include "esphome/components/text_sensor/text_sensor.h"
#endif
namespace esphome::emontx {
@@ -27,6 +30,9 @@ static constexpr size_t MAX_LINE_LENGTH = 1024;
* extracts tags and values, and publishes them to registered sensors.
*/
class EmonTx final : public Component, public uart::UARTDevice {
#ifdef USE_EMONTX_VERSION
SUB_TEXT_SENSOR(version)
#endif
public:
EmonTx() = default;
@@ -60,6 +66,10 @@ class EmonTx final : public Component, public uart::UARTDevice {
protected:
void parse_json_(const char *data, size_t len);
#ifdef USE_EMONTX_VERSION
void request_version_();
void parse_version_line_(const char *line, size_t len);
#endif
#ifdef USE_SENSOR
FixedVector<std::pair<const char *, sensor::Sensor *>> sensors_{};
@@ -71,6 +81,9 @@ class EmonTx final : public Component, public uart::UARTDevice {
bool was_paused_{false};
bool lock_on_reboot_{false};
bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line
#ifdef USE_EMONTX_VERSION
uint32_t last_msg_number_{0};
#endif
std::array<char, MAX_LINE_LENGTH + 1> buffer_{};
};
@@ -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_EMONTX_ID, EmonTx
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx),
cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_EMONTX_ID])
text_sensors = text_sensor.sub_text_sensors(config)
if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None:
cg.add_define("USE_EMONTX_VERSION")
+26 -14
View File
@@ -111,24 +111,36 @@ void ES8388::setup() {
// Power on ADC, Enable LIN&RIN, Power off MICBIAS, set int1lp to low power mode
ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCPOWER, 0x09));
#ifdef USE_SELECT
#ifdef USE_ES8388_SELECT
if (this->dac_output_select_ != nullptr) {
auto dac_power = this->get_dac_power();
if (dac_power.has_value()) {
if (this->dac_output_select_->has_index(dac_power.value())) {
this->dac_output_select_->publish_state(dac_power.value());
} else {
ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value());
if (this->dac_output_initial_.has_value()) {
const DacOutputLine line = this->dac_output_initial_.value();
ES8388_ERROR_FAILED(this->set_dac_output(line));
this->dac_output_select_->publish_state(static_cast<size_t>(line));
} else {
auto dac_power = this->get_dac_power();
if (dac_power.has_value()) {
if (this->dac_output_select_->has_index(dac_power.value())) {
this->dac_output_select_->publish_state(dac_power.value());
} else {
ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value());
}
}
}
}
if (this->adc_input_mic_select_ != nullptr) {
auto mic_input = this->get_mic_input();
if (mic_input.has_value()) {
if (this->adc_input_mic_select_->has_index(mic_input.value())) {
this->adc_input_mic_select_->publish_state(mic_input.value());
} else {
ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value());
if (this->adc_input_mic_initial_.has_value()) {
const AdcInputMicLine line = this->adc_input_mic_initial_.value();
ES8388_ERROR_FAILED(this->set_adc_input_mic(line));
this->adc_input_mic_select_->publish_state(static_cast<size_t>(line));
} else {
auto mic_input = this->get_mic_input();
if (mic_input.has_value()) {
if (this->adc_input_mic_select_->has_index(mic_input.value())) {
this->adc_input_mic_select_->publish_state(mic_input.value());
} else {
ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value());
}
}
}
}
@@ -138,7 +150,7 @@ void ES8388::setup() {
void ES8388::dump_config() {
ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:");
LOG_I2C_DEVICE(this);
#ifdef USE_SELECT
#ifdef USE_ES8388_SELECT
LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_);
LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_);
#endif
+13 -2
View File
@@ -5,7 +5,7 @@
#include "esphome/components/audio_dac/audio_dac.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#ifdef USE_SELECT
#ifdef USE_ES8388_SELECT
#include "esphome/components/select/select.h"
#endif
@@ -26,7 +26,7 @@ enum AdcInputMicLine : uint8_t {
};
class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
#ifdef USE_SELECT
#ifdef USE_ES8388_SELECT
SUB_SELECT(dac_output)
SUB_SELECT(adc_input_mic)
#endif
@@ -68,11 +68,22 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I
bool set_dac_output(DacOutputLine line);
bool set_adc_input_mic(AdcInputMicLine line);
#ifdef USE_ES8388_SELECT
/// Line to apply in setup() instead of keeping what the codec reports.
void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; }
void set_adc_input_mic_initial(AdcInputMicLine line) { this->adc_input_mic_initial_ = line; }
#endif
protected:
/// @brief Mutes or unmutes the DAC audio out
/// @param mute_state True to mute, false to unmute
/// @return True if successful and false otherwise
bool set_mute_state_(bool mute_state);
#ifdef USE_ES8388_SELECT
optional<DacOutputLine> dac_output_initial_{};
optional<AdcInputMicLine> adc_input_mic_initial_{};
#endif
};
} // namespace esphome::es8388
+43 -3
View File
@@ -1,7 +1,11 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401
from esphome.const import (
CONF_INITIAL_OPTION,
ENTITY_CATEGORY_CONFIG,
ICON_CHIP, # noqa: F401
)
from esphome.types import ConfigType
from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns
@@ -9,6 +13,21 @@ from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns
CONF_DAC_OUTPUT = "dac_output"
CONF_ADC_INPUT_MIC = "adc_input_mic"
DacOutputLine = es8388_ns.enum("DacOutputLine")
DAC_OUTPUT_OPTIONS = {
"LINE1": DacOutputLine.DAC_OUTPUT_LINE1,
"LINE2": DacOutputLine.DAC_OUTPUT_LINE2,
"BOTH": DacOutputLine.DAC_OUTPUT_BOTH,
}
DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS)
AdcInputMicLine = es8388_ns.enum("AdcInputMicLine")
ADC_INPUT_MIC_OPTIONS = {
"LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1,
"LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2,
"DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE,
}
ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS)
DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select)
ADCInputMicSelect = es8388_ns.class_("ADCInputMicSelect", select.Select)
@@ -19,24 +38,45 @@ CONFIG_SCHEMA = cv.All(
DacOutputSelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_CHIP,
).extend(
{
cv.Optional(CONF_INITIAL_OPTION): cv.enum(
DAC_OUTPUT_OPTIONS, upper=True
),
}
),
cv.Optional(CONF_ADC_INPUT_MIC): select.select_schema(
ADCInputMicSelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_CHIP,
).extend(
{
cv.Optional(CONF_INITIAL_OPTION): cv.enum(
ADC_INPUT_MIC_OPTIONS, upper=True
),
}
),
}
)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ES8388_SELECT")
hub = await cg.get_variable(config[CONF_ES8388_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"]
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=DAC_OUTPUT_OPTION_NAMES
)
await selects(
CONF_ADC_INPUT_MIC,
hub.set_adc_input_mic_select,
options=["LINE1", "LINE2", "DIFFERENCE"],
options=ADC_INPUT_MIC_OPTION_NAMES,
)
if (
initial := config.get(CONF_DAC_OUTPUT, {}).get(CONF_INITIAL_OPTION)
) is not None:
cg.add(hub.set_dac_output_initial(initial))
if (
initial := config.get(CONF_ADC_INPUT_MIC, {}).get(CONF_INITIAL_OPTION)
) is not None:
cg.add(hub.set_adc_input_mic_initial(initial))
@@ -282,7 +282,7 @@ void ESP32Camera::loop() {
}
this->frame_count_++;
if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) {
ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000);
ESP_LOGD(TAG, "Received %u images in last %" PRIu32 "s", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000);
this->last_log_time_ = now;
this->frame_count_ = 0;
}
@@ -0,0 +1,2 @@
CODEOWNERS = ["@tuct"]
DOMAIN = "filter_lifetime"
@@ -0,0 +1,112 @@
#include "filter_lifetime.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::filter_lifetime {
ESPHOME_LOG_TAG(TAG, "filter_lifetime");
static constexpr uint32_t SECONDS_PER_HOUR = 3600;
static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f;
void FilterLifetime::setup() {
this->pref_ = this->make_entity_preference<uint32_t>();
this->pref_.load(&this->runtime_seconds_);
this->last_update_ = millis();
this->publish_values_();
}
void FilterLifetime::update() {
const uint32_t now = App.get_loop_component_start_time();
const uint32_t elapsed_ms = now - this->last_update_;
this->last_update_ = now;
if (this->get_is_on_()) {
float speed = this->get_current_speed_();
if (std::isnan(speed))
speed = 0.0f;
speed = std::clamp(speed, 0.0f, 100.0f);
const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR;
this->carry_ms_ += static_cast<uint32_t>(elapsed_ms * (speed / 100.0f));
this->runtime_seconds_ += this->carry_ms_ / 1000;
this->carry_ms_ %= 1000;
// Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266.
if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours)
this->pref_.save(&this->runtime_seconds_);
ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_);
}
this->publish_values_();
}
void FilterLifetime::publish_values_() {
const float runtime_minutes = this->runtime_seconds_ / 60.0f;
const float max_minutes = this->max_lifetime_minutes_;
const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes);
this->publish_state(100.0f * remaining_minutes / max_minutes);
if (this->runtime_hours_sensor_ != nullptr)
this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f);
if (this->remaining_days_sensor_ != nullptr)
this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY);
}
void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); }
void FilterLifetime::reset_filter() {
this->runtime_seconds_ = 0;
this->carry_ms_ = 0;
this->pref_.save(&this->runtime_seconds_);
this->publish_values_();
ESP_LOGI(TAG, "Filter lifetime reset");
}
bool FilterLifetime::get_is_on_() {
#ifdef USE_BINARY_SENSOR
// Reads false until the sensor first publishes, so nothing accumulates before then.
if (this->is_on_sensor_ != nullptr)
return this->is_on_sensor_->state;
#endif
if (this->is_on_lambda_ != nullptr)
return this->is_on_lambda_();
return true;
}
float FilterLifetime::get_current_speed_() {
if (this->current_speed_sensor_ != nullptr)
return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0
if (this->current_speed_lambda_ != nullptr)
return this->current_speed_lambda_();
return 100.0f;
}
void FilterLifetime::dump_config() {
LOG_SENSOR("", "Filter Lifetime", this);
ESP_LOGCONFIG(TAG,
" Max lifetime: %" PRIu32 " min (%.1f days)\n"
" Runtime: %.1f h",
this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY,
this->runtime_seconds_ / 3600.0f);
#ifdef USE_BINARY_SENSOR
if (this->is_on_sensor_ != nullptr) {
LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_);
} else
#endif
{
ESP_LOGCONFIG(TAG, " Is-on: %s",
this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always"));
}
if (this->current_speed_sensor_ != nullptr) {
LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_);
} else {
ESP_LOGCONFIG(TAG, " Speed: %s",
this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%"));
}
LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_);
LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_);
LOG_UPDATE_INTERVAL(this);
}
} // namespace esphome::filter_lifetime
@@ -0,0 +1,50 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::filter_lifetime {
class FilterLifetime : public sensor::Sensor, public PollingComponent {
public:
void setup() override;
void update() override;
void dump_config() override;
void on_shutdown() override;
void reset_filter();
void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; }
void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; }
#ifdef USE_BINARY_SENSOR
void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; }
#endif
void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; }
void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; }
void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; }
void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; }
protected:
bool get_is_on_();
float get_current_speed_();
void publish_values_();
bool (*is_on_lambda_)(){nullptr};
float (*current_speed_lambda_)(){nullptr};
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *is_on_sensor_{nullptr};
#endif
sensor::Sensor *current_speed_sensor_{nullptr};
sensor::Sensor *runtime_hours_sensor_{nullptr};
sensor::Sensor *remaining_days_sensor_{nullptr};
ESPPreferenceObject pref_;
uint32_t max_lifetime_minutes_{0};
uint32_t last_update_{0};
uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted
uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second
};
} // namespace esphome::filter_lifetime
@@ -0,0 +1,94 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import binary_sensor, sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_DURATION,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_HOUR,
UNIT_PERCENT,
)
from esphome.types import ConfigType
CONF_MAX_LIFETIME = "max_lifetime"
CONF_IS_ON = "is_on"
CONF_IS_ON_SENSOR = "is_on_sensor"
CONF_CURRENT_SPEED = "current_speed"
CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor"
CONF_RUNTIME_HOURS = "runtime_hours"
CONF_REMAINING_DAYS = "remaining_days"
UNIT_DAY = "d"
filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime")
FilterLifetime = filter_lifetime_ns.class_(
"FilterLifetime", sensor.Sensor, cg.PollingComponent
)
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
FilterLifetime,
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=2,
icon="mdi:air-filter",
state_class=STATE_CLASS_MEASUREMENT,
)
.extend(
{
cv.Required(CONF_MAX_LIFETIME): cv.All(
cv.positive_time_period_minutes,
cv.Range(min=cv.TimePeriod(minutes=1)),
),
cv.Optional(CONF_IS_ON): cv.returning_lambda,
cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor),
cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda,
cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor),
cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema(
unit_of_measurement=UNIT_HOUR,
accuracy_decimals=1,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_TOTAL_INCREASING,
icon="mdi:timer-outline",
),
cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema(
unit_of_measurement=UNIT_DAY,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
icon="mdi:calendar-clock",
),
}
)
.extend(cv.polling_component_schema("60s")),
cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR),
cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR),
)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME]))
if (is_on := config.get(CONF_IS_ON)) is not None:
cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool)))
elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None:
cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor)))
if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None:
cg.add(
var.set_current_speed_lambda(await cg.templatable(current_speed, [], float))
)
elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None:
cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor)))
sensors = sensor.sub_sensors(config)
await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor)
await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor)
automation.register_apply_action(
"filter_lifetime.reset_filter",
cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}),
automation.ApplyCall("reset_filter()"),
)
@@ -0,0 +1,2 @@
DOMAIN = "haier_tundra"
CODEOWNERS = ["@fauxpark"]
@@ -0,0 +1,43 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import CONF_LIGHT
from esphome.types import ConfigType
from ..climate import HaierTundra, haier_tundra_ns
CODEOWNERS = ["@fauxpark"]
HaierTundraButton = haier_tundra_ns.class_(
"HaierTundraButton", button.Button, cg.Component
)
ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True)
ICON_LED_OUTLINE = "mdi:led-outline"
ICON_SPRAY_BOTTLE = "mdi:spray-bottle"
CONF_HAIER_TUNDRA_ID = "haier_tundra_id"
CONF_SELF_CLEAN = "self_clean"
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra),
cv.Optional(CONF_LIGHT): button.button_schema(
HaierTundraButton, icon=ICON_LED_OUTLINE
),
cv.Optional(CONF_SELF_CLEAN): button.button_schema(
HaierTundraButton, icon=ICON_SPRAY_BOTTLE
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID])
for key, kind in (
(CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT),
(CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN),
):
if (conf := config.get(key)) is not None:
var = await button.new_button(conf, hub, kind)
await cg.register_component(var, conf)
@@ -0,0 +1,18 @@
#include "haier_tundra_button.h"
#include "esphome/core/log.h"
namespace esphome::haier_tundra {
ESPHOME_LOG_TAG(TAG, "haier_tundra.button");
void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); }
void HaierTundraButton::press_action() {
if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) {
this->parent_->toggle_light();
} else {
this->parent_->start_self_clean();
}
}
} // namespace esphome::haier_tundra
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/button/button.h"
#include "esphome/components/haier_tundra/haier_tundra.h"
namespace esphome::haier_tundra {
enum class ButtonKind : uint8_t {
BUTTON_KIND_LIGHT,
BUTTON_KIND_SELF_CLEAN,
};
class HaierTundraButton final : public button::Button, public Component, public Parented<HaierTundra> {
public:
HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {}
void dump_config() override;
protected:
void press_action() override;
ButtonKind kind_;
};
} // namespace esphome::haier_tundra
@@ -0,0 +1,51 @@
import esphome.codegen as cg
from esphome.components import climate_ir, remote_base
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PLATFORM
import esphome.final_validate as fv
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
haier_tundra_ns = cg.esphome_ns.namespace("haier_tundra")
HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR)
CONF_UNIT = "unit"
Unit = haier_tundra_ns.enum("Unit")
UNITS = {
"a": Unit.HAIER_HEADER_UNIT_A,
"b": Unit.HAIER_HEADER_UNIT_B,
}
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend(
{
cv.Optional(CONF_UNIT, default="a"): cv.enum(UNITS, lower=True),
}
)
def _final_validate_unit(config: ConfigType) -> None:
# Each transmitter may drive at most one unit A and one unit B
climates = fv.full_config.get()["climate"]
seen = set()
for climate in climates:
if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra":
unit_tx_pair = (
climate[CONF_UNIT],
climate[remote_base.CONF_TRANSMITTER_ID],
)
if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]:
raise cv.Invalid(
"Only one of each unit is allowed per transmitter", path=[CONF_UNIT]
)
seen.add(unit_tx_pair)
FINAL_VALIDATE_SCHEMA = _final_validate_unit
async def to_code(config: ConfigType) -> None:
remote_base.request_protocol("haier")
await climate_ir.new_climate_ir(config, config[CONF_UNIT])
@@ -0,0 +1,260 @@
#include "haier_tundra.h"
namespace esphome::haier_tundra {
// Number of bytes in decoded packet sans checksum
static constexpr uint8_t HAIER_TUNDRA_FRAME_SIZE = 13;
void HaierTundra::transmit_state() {
// Button value does not seem to matter too much except
// when toggling light, as it has no bit flag of its own
// So in the default case we can simply set it to POWER
this->transmit_(HAIER_BUTTON_POWER);
}
void HaierTundra::transmit_(uint8_t button) {
// Force Quiet and Turbo off if we're not in Cool or Heat mode
if (this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT) {
#ifdef USE_SWITCH
if (this->quiet_mode_switch_ != nullptr) {
this->quiet_mode_switch_->publish_state(false);
}
if (this->turbo_mode_switch_ != nullptr) {
this->turbo_mode_switch_->publish_state(false);
}
#endif
this->quiet_mode_ = false;
this->turbo_mode_ = false;
}
const uint8_t frame[HAIER_TUNDRA_FRAME_SIZE] = {
this->unit_,
static_cast<uint8_t>(this->temperature_() << 4 | this->vertical_swing_()),
static_cast<uint8_t>(this->horizontal_swing_() << 5),
static_cast<uint8_t>(this->health_() << 1),
static_cast<uint8_t>(this->power_() << 6),
static_cast<uint8_t>(this->fan_speed_() << 5),
static_cast<uint8_t>(this->quiet_() << 7 | this->turbo_() << 6),
static_cast<uint8_t>(this->operation_mode_() << 5),
0x00,
0x00,
static_cast<uint8_t>((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4),
0x00,
button,
};
auto transmit = this->transmitter_->transmit();
remote_base::HaierProtocol().encode(transmit.get_data(), frame, sizeof(frame));
transmit.perform();
}
uint8_t HaierTundra::temperature_() {
return (uint8_t) roundf(clamp<float>(this->target_temperature, HAIER_TEMP_MIN, HAIER_TEMP_MAX)) - HAIER_TEMP_MIN;
}
uint8_t HaierTundra::vertical_swing_() {
if (this->mode == climate::CLIMATE_MODE_OFF) {
return HAIER_SWING_V_OFF;
}
switch (this->swing_mode) {
case climate::CLIMATE_SWING_VERTICAL:
case climate::CLIMATE_SWING_BOTH:
return HAIER_SWING_V_AUTO;
default:
if (this->mode == climate::CLIMATE_MODE_HEAT) {
return HAIER_SWING_V_1BOT;
}
return HAIER_SWING_V_1TOP; // Cool/Dry/Auto
}
}
uint8_t HaierTundra::horizontal_swing_() {
switch (this->swing_mode) {
case climate::CLIMATE_SWING_HORIZONTAL:
case climate::CLIMATE_SWING_BOTH:
return HAIER_SWING_H_AUTO;
default:
return HAIER_SWING_H_MIDDLE;
}
}
uint8_t HaierTundra::health_() { return this->health_mode_ ? 1 : 0; }
uint8_t HaierTundra::power_() { return this->mode == climate::CLIMATE_MODE_OFF ? 0 : 1; }
uint8_t HaierTundra::fan_speed_() {
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
case climate::CLIMATE_FAN_LOW:
return HAIER_FAN_LOW;
case climate::CLIMATE_FAN_MEDIUM:
return HAIER_FAN_MED;
case climate::CLIMATE_FAN_HIGH:
return HAIER_FAN_HIGH;
case climate::CLIMATE_FAN_AUTO:
default:
return HAIER_FAN_AUTO;
}
}
uint8_t HaierTundra::quiet_() { return this->quiet_mode_ ? 1 : 0; }
uint8_t HaierTundra::turbo_() { return this->turbo_mode_ ? 1 : 0; }
uint8_t HaierTundra::operation_mode_() {
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
return HAIER_MODE_COOL;
case climate::CLIMATE_MODE_DRY:
return HAIER_MODE_DRY;
case climate::CLIMATE_MODE_HEAT:
return HAIER_MODE_HEAT;
case climate::CLIMATE_MODE_FAN_ONLY:
return HAIER_MODE_FAN;
case climate::CLIMATE_MODE_HEAT_COOL:
default:
return HAIER_MODE_AUTO;
}
}
bool HaierTundra::on_receive(remote_base::RemoteReceiveData data) {
auto haier = remote_base::HaierProtocol().decode(data);
if (haier.has_value()) {
return this->on_haier_(*haier);
}
return false;
}
bool HaierTundra::on_haier_(const remote_base::HaierData &haier) {
// Verify frame length and header
if (haier.data.size() != HAIER_TUNDRA_FRAME_SIZE || haier.data[0] != this->unit_) {
return false;
}
this->target_temperature = this->get_temperature_(haier.data[1] >> 4);
this->swing_mode = this->get_swing_mode_(haier.data[1] & 0xF, haier.data[2] >> 5);
this->health_mode_ = haier.data[3] & 0x2;
this->mode = this->get_operation_mode_(haier.data[4] & 0x40, haier.data[7] >> 5);
this->fan_mode = this->get_fan_speed_(haier.data[5] >> 5);
this->quiet_mode_ = haier.data[6] & 0x80;
this->turbo_mode_ = haier.data[6] & 0x40;
#ifdef USE_SWITCH
if (this->health_mode_switch_ != nullptr) {
this->health_mode_switch_->publish_state(this->health_mode_);
}
if (this->quiet_mode_switch_ != nullptr) {
this->quiet_mode_switch_->publish_state(this->quiet_mode_);
}
if (this->turbo_mode_switch_ != nullptr) {
this->turbo_mode_switch_->publish_state(this->turbo_mode_);
}
#endif
this->publish_state();
return true;
}
float HaierTundra::get_temperature_(uint8_t temp) { return temp + HAIER_TEMP_MIN; }
climate::ClimateSwingMode HaierTundra::get_swing_mode_(uint8_t swing_v, uint8_t swing_h) {
if (swing_v == HAIER_SWING_V_AUTO) {
if (swing_h == HAIER_SWING_H_AUTO) {
return climate::CLIMATE_SWING_BOTH;
}
return climate::CLIMATE_SWING_VERTICAL;
} else if (swing_h == HAIER_SWING_H_AUTO) {
return climate::CLIMATE_SWING_HORIZONTAL;
}
return climate::CLIMATE_SWING_OFF;
}
climate::ClimateMode HaierTundra::get_operation_mode_(uint8_t power, uint8_t mode) {
if (!power) {
return climate::CLIMATE_MODE_OFF;
}
switch (mode) {
case HAIER_MODE_COOL:
return climate::CLIMATE_MODE_COOL;
case HAIER_MODE_DRY:
return climate::CLIMATE_MODE_DRY;
case HAIER_MODE_HEAT:
return climate::CLIMATE_MODE_HEAT;
case HAIER_MODE_FAN:
return climate::CLIMATE_MODE_FAN_ONLY;
case HAIER_MODE_AUTO:
default:
return climate::CLIMATE_MODE_HEAT_COOL;
}
}
climate::ClimateFanMode HaierTundra::get_fan_speed_(uint8_t fan) {
switch (fan) {
case HAIER_FAN_LOW:
return climate::CLIMATE_FAN_LOW;
case HAIER_FAN_MED:
return climate::CLIMATE_FAN_MEDIUM;
case HAIER_FAN_HIGH:
return climate::CLIMATE_FAN_HIGH;
case HAIER_FAN_AUTO:
default:
return climate::CLIMATE_FAN_AUTO;
}
}
void HaierTundra::set_health_mode(bool health, bool transmit) {
this->health_mode_ = health;
if (transmit && this->mode != climate::CLIMATE_MODE_OFF) {
this->transmit_(HAIER_BUTTON_HEALTH);
}
}
void HaierTundra::set_quiet_mode(bool quiet, bool transmit) {
// Quiet & Turbo cannot be enabled at the same time
if (quiet) {
#ifdef USE_SWITCH
if (this->turbo_mode_switch_ != nullptr) {
this->turbo_mode_switch_->publish_state(false);
}
#endif
this->turbo_mode_ = false;
}
this->quiet_mode_ = quiet;
if (transmit)
this->transmit_(HAIER_BUTTON_TURBO_QUIET);
}
void HaierTundra::set_turbo_mode(bool turbo, bool transmit) {
// Quiet & Turbo cannot be enabled at the same time
if (turbo) {
#ifdef USE_SWITCH
if (this->quiet_mode_switch_ != nullptr) {
this->quiet_mode_switch_->publish_state(false);
}
#endif
this->quiet_mode_ = false;
}
this->turbo_mode_ = turbo;
if (transmit)
this->transmit_(HAIER_BUTTON_TURBO_QUIET);
}
void HaierTundra::toggle_light() {
if (this->mode != climate::CLIMATE_MODE_OFF) {
this->transmit_(HAIER_BUTTON_LIGHT);
}
}
void HaierTundra::start_self_clean() {
// Can be started when off
this->transmit_(HAIER_BUTTON_SELF_CLEAN);
}
} // namespace esphome::haier_tundra
@@ -0,0 +1,139 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/components/remote_base/haier_protocol.h"
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::haier_tundra {
// Byte 0 - Header value (Unit ID)
enum Unit : uint8_t {
HAIER_HEADER_UNIT_B = 0x59,
HAIER_HEADER_UNIT_A = 0xA6,
};
// Byte 1[7:4] - Temperature (˚C) 0x0..0xE
static constexpr uint8_t HAIER_TEMP_MIN = 16;
static constexpr uint8_t HAIER_TEMP_MAX = 30;
// Byte 1[3:0] - Vertical Swing
static constexpr uint8_t HAIER_SWING_V_OFF = 0x0; // Power off value; from Auto, stops at current position
static constexpr uint8_t HAIER_SWING_V_2TOP = 0x1; // [|/ ]
static constexpr uint8_t HAIER_SWING_V_1TOP = 0x2; // [| ] (Not available in Heat mode)
static constexpr uint8_t HAIER_SWING_V_2BOT = 0x3; // [ //]
static constexpr uint8_t HAIER_SWING_V_1BOT = 0xA; // [ /] (Heat mode only)
static constexpr uint8_t HAIER_SWING_V_AUTO = 0xC;
// Byte 2[7:5] - Horizontal Swing
static constexpr uint8_t HAIER_SWING_H_MIDDLE = 0x0; // [ || ]
static constexpr uint8_t HAIER_SWING_H_LEFT = 0x3; // [/ ]
static constexpr uint8_t HAIER_SWING_H_MIDDLE_LEFT = 0x4; // [ | ]
static constexpr uint8_t HAIER_SWING_H_MIDDLE_RIGHT = 0x5; // [ | ]
static constexpr uint8_t HAIER_SWING_H_RIGHT = 0x6; // [ \]
static constexpr uint8_t HAIER_SWING_H_AUTO = 0x7; // [/||\]
// Byte 3[7:5] - Timer Mode
static constexpr uint8_t HAIER_TIMER_MODE_DISABLED = 0x0;
static constexpr uint8_t HAIER_TIMER_MODE_OFF = 0x1; // OFF
static constexpr uint8_t HAIER_TIMER_MODE_ON = 0x2; // ON
static constexpr uint8_t HAIER_TIMER_MODE_ON_OFF = 0x4; // ON → OFF
static constexpr uint8_t HAIER_TIMER_MODE_OFF_ON = 0x5; // ON ← OFF
// Byte 3[1] - Health
// Byte 4[6] - Power
// Byte 5[7:5] - Fan Speed
static constexpr uint8_t HAIER_FAN_HIGH = 0x1;
static constexpr uint8_t HAIER_FAN_MED = 0x2;
static constexpr uint8_t HAIER_FAN_LOW = 0x3;
static constexpr uint8_t HAIER_FAN_AUTO = 0x5;
// Byte 5[4:0] - Timer Off Hours (0-23) 0x00..0x17
// Byte 6[7] - Quiet Mode
// Byte 6[6] - Turbo Mode
// Byte 6[5:0] - Timer Off Minutes (0-59) 0x00..0x3B
// Byte 7[7:5] - Mode
static constexpr uint8_t HAIER_MODE_AUTO = 0x0;
static constexpr uint8_t HAIER_MODE_COOL = 0x1;
static constexpr uint8_t HAIER_MODE_DRY = 0x2;
static constexpr uint8_t HAIER_MODE_HEAT = 0x4;
static constexpr uint8_t HAIER_MODE_FAN = 0x6;
// Byte 7[4:0] - Timer On Hours (0-23) 0x00..0x17
// Byte 8[7] - Sleep Mode
// Byte 8[5:0] - Timer On Minutes (0-59) 0x00..0x3B
// Byte 10[4] - Self Clean
// Byte 12 - Button
static constexpr uint8_t HAIER_BUTTON_TEMP_UP = 0x00;
static constexpr uint8_t HAIER_BUTTON_TEMP_DOWN = 0x01;
static constexpr uint8_t HAIER_BUTTON_SWING_V = 0x02;
static constexpr uint8_t HAIER_BUTTON_SWING_H = 0x03;
static constexpr uint8_t HAIER_BUTTON_FAN_SPEED = 0x04;
static constexpr uint8_t HAIER_BUTTON_POWER = 0x05;
static constexpr uint8_t HAIER_BUTTON_MODE = 0x06;
static constexpr uint8_t HAIER_BUTTON_HEALTH = 0x07;
static constexpr uint8_t HAIER_BUTTON_TURBO_QUIET = 0x08;
static constexpr uint8_t HAIER_BUTTON_SLEEP = 0x0B;
static constexpr uint8_t HAIER_BUTTON_TIMER = 0x10;
static constexpr uint8_t HAIER_BUTTON_LIGHT = 0x15;
static constexpr uint8_t HAIER_BUTTON_SELF_CLEAN = 0x19;
class HaierTundra final : public climate_ir::ClimateIR {
public:
explicit HaierTundra(Unit unit)
: climate_ir::ClimateIR(HAIER_TEMP_MIN, HAIER_TEMP_MAX, 1.0f, true, true,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}),
unit_(unit) {}
#ifdef USE_SWITCH
SUB_SWITCH(health_mode);
SUB_SWITCH(quiet_mode);
SUB_SWITCH(turbo_mode);
#endif
void set_health_mode(bool health, bool transmit = true);
void set_quiet_mode(bool quiet, bool transmit = true);
void set_turbo_mode(bool turbo, bool transmit = true);
void toggle_light();
void start_self_clean();
protected:
// Transmit via IR the state of this climate controller.
void transmit_state() override;
void transmit_(uint8_t button);
uint8_t temperature_();
uint8_t vertical_swing_();
uint8_t horizontal_swing_();
uint8_t health_();
uint8_t power_();
uint8_t fan_speed_();
uint8_t quiet_();
uint8_t turbo_();
uint8_t operation_mode_();
// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
bool on_haier_(const remote_base::HaierData &haier);
float get_temperature_(uint8_t temp);
climate::ClimateSwingMode get_swing_mode_(uint8_t swing_v, uint8_t swing_h);
climate::ClimateMode get_operation_mode_(uint8_t power, uint8_t mode);
climate::ClimateFanMode get_fan_speed_(uint8_t fan);
Unit unit_;
bool health_mode_{false};
bool quiet_mode_{false};
bool turbo_mode_{false};
};
} // namespace esphome::haier_tundra
@@ -0,0 +1,60 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from ..climate import HaierTundra, haier_tundra_ns
CODEOWNERS = ["@fauxpark"]
HaierTundraSwitch = haier_tundra_ns.class_(
"HaierTundraSwitch", switch.Switch, cg.Component
)
SwitchKind = haier_tundra_ns.enum("SwitchKind", is_class=True)
ICON_LEAF = "mdi:leaf"
ICON_ARROW_DOWN_RIGHT = "mdi:arrow-down-right"
ICON_ARROW_UP_RIGHT_BOLD = "mdi:arrow-up-right-bold"
CONF_HAIER_TUNDRA_ID = "haier_tundra_id"
CONF_HEALTH_MODE = "health_mode"
CONF_QUIET_MODE = "quiet_mode"
CONF_TURBO_MODE = "turbo_mode"
def _switch_schema(icon: str) -> cv.Schema:
return switch.switch_schema(
HaierTundraSwitch,
icon=icon,
default_restore_mode="RESTORE_DEFAULT_OFF",
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra),
cv.Optional(CONF_HEALTH_MODE): _switch_schema(ICON_LEAF),
cv.Optional(CONF_QUIET_MODE): _switch_schema(ICON_ARROW_DOWN_RIGHT),
cv.Optional(CONF_TURBO_MODE): _switch_schema(ICON_ARROW_UP_RIGHT_BOLD),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID])
switches = switch.sub_switches(config)
if sw := await switches(
CONF_HEALTH_MODE, hub.set_health_mode_switch, hub, SwitchKind.SWITCH_KIND_HEALTH
):
await cg.register_component(sw, config[CONF_HEALTH_MODE])
if sw := await switches(
CONF_QUIET_MODE, hub.set_quiet_mode_switch, hub, SwitchKind.SWITCH_KIND_QUIET
):
await cg.register_component(sw, config[CONF_QUIET_MODE])
if sw := await switches(
CONF_TURBO_MODE, hub.set_turbo_mode_switch, hub, SwitchKind.SWITCH_KIND_TURBO
):
await cg.register_component(sw, config[CONF_TURBO_MODE])
@@ -0,0 +1,43 @@
#include "haier_tundra_switch.h"
#include "esphome/core/log.h"
namespace esphome::haier_tundra {
ESPHOME_LOG_TAG(TAG, "haier_tundra.switch");
void HaierTundraSwitch::setup() {
auto initial = this->get_initial_state_with_restore_mode();
if (initial.has_value()) {
const bool state = initial.value();
this->apply_(state, false);
this->publish_state(state);
}
}
void HaierTundraSwitch::dump_config() { LOG_SWITCH("", "Haier Tundra Switch", this); }
void HaierTundraSwitch::write_state(bool state) {
// Quiet and Turbo can only be turned on in Cool or Heat mode
if (state && this->kind_ != SwitchKind::SWITCH_KIND_HEALTH && this->parent_->mode != climate::CLIMATE_MODE_COOL &&
this->parent_->mode != climate::CLIMATE_MODE_HEAT) {
return;
}
this->apply_(state, true);
this->publish_state(state);
}
void HaierTundraSwitch::apply_(bool state, bool transmit) {
switch (this->kind_) {
case SwitchKind::SWITCH_KIND_HEALTH:
this->parent_->set_health_mode(state, transmit);
break;
case SwitchKind::SWITCH_KIND_QUIET:
this->parent_->set_quiet_mode(state, transmit);
break;
case SwitchKind::SWITCH_KIND_TURBO:
this->parent_->set_turbo_mode(state, transmit);
break;
}
}
} // namespace esphome::haier_tundra
@@ -0,0 +1,28 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/switch/switch.h"
#include "esphome/components/haier_tundra/haier_tundra.h"
namespace esphome::haier_tundra {
enum class SwitchKind : uint8_t {
SWITCH_KIND_HEALTH,
SWITCH_KIND_QUIET,
SWITCH_KIND_TURBO,
};
class HaierTundraSwitch final : public switch_::Switch, public Component, public Parented<HaierTundra> {
public:
HaierTundraSwitch(HaierTundra *parent, SwitchKind kind) : Parented(parent), kind_(kind) {}
void setup() override;
void dump_config() override;
protected:
void write_state(bool state) override;
void apply_(bool state, bool transmit);
SwitchKind kind_;
};
} // namespace esphome::haier_tundra
+13
View File
@@ -0,0 +1,13 @@
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.motion import MotionComponent
CODEOWNERS = ["@lboue"]
DOMAIN = "icm20689"
CONF_ICM20689_ID = "icm20689_id"
# C++ namespace / class
icm20689_ns = cg.esphome_ns.namespace("icm20689")
ICM20689Component = icm20689_ns.class_(
"ICM20689Component", MotionComponent, spi.SPIDevice
)
+126
View File
@@ -0,0 +1,126 @@
#include "icm20689.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cstddef>
#include <cstdint>
namespace esphome::icm20689 {
ESPHOME_LOG_TAG(TAG, "icm20689");
namespace {
// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range.
constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; }
constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; }
} // namespace
void ICM20689Component::write_register_(uint8_t reg, uint8_t value) {
this->enable();
this->write_byte(reg & 0x7F);
this->write_byte(value);
this->disable();
}
void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) {
this->enable();
this->write_byte(reg | ICM20689_READ_BIT);
this->read_array(data, len);
this->disable();
}
void ICM20689Component::setup() {
MotionComponent::setup();
this->spi_setup();
if (!this->spi_is_ready()) {
ESP_LOGE(TAG, "SPI device is not ready");
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Datasheet 4.20: a soft reset must be the first operation after power-up,
// before any register initialization. WHO_AM_I is only valid afterwards.
this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET);
delay(100); // NOLINT
// The chip comes out of reset in sleep mode: wake it and select the
// auto-selected best clock source (required for full gyro performance).
this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO);
// USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake.
// Datasheet 6.1: disable the I2C interface when using SPI.
this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS);
uint8_t who_am_i = 0;
this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1);
if (who_am_i != ICM20689_WHO_AM_I_VALUE) {
ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE);
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Allow the sensor to settle after waking from sleep.
delay(50); // NOLINT
// Configure the accelerometer and gyroscope full-scale ranges.
this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast<uint8_t>(this->accel_range_) << 3);
this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast<uint8_t>(this->gyro_range_) << 3);
}
void ICM20689Component::dump_config() {
ESP_LOGCONFIG(TAG, "ICM20689 IMU:");
LOG_PIN(" CS Pin: ", this->cs_);
if (this->is_failed()) {
ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL);
return;
}
ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast<uint8_t>(this->accel_range_));
ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast<uint8_t>(this->gyro_range_));
MotionComponent::dump_config();
}
bool ICM20689Component::update_data(motion::MotionData &data) {
if (this->is_failed())
return false;
const uint8_t accel_range = static_cast<uint8_t>(this->accel_range_);
const uint8_t gyro_range = static_cast<uint8_t>(this->gyro_range_);
// Read one contiguous sample:
// 0..5 accelerometer X/Y/Z
// 6..7 temperature
// 8..13 gyroscope X/Y/Z
uint8_t raw_data[14] = {};
this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data));
const float accel_factor = accel_scale(accel_range);
data.acceleration[motion::X_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[0], raw_data[1])) * accel_factor;
data.acceleration[motion::Y_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[2], raw_data[3])) * accel_factor;
data.acceleration[motion::Z_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[4], raw_data[5])) * accel_factor;
const float gyro_factor = gyro_scale(gyro_range);
data.angular_rate[motion::X_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor;
data.angular_rate[motion::Y_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor;
data.angular_rate[motion::Z_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor;
if (!this->temperature_callback_.empty()) {
// Datasheet formula: temperature (°C) = raw / 326.8 + 25.
const float temperature = static_cast<int16_t>(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f;
this->temperature_callback_.call(temperature);
}
return true;
}
} // namespace esphome::icm20689
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include "esphome/components/motion/motion_component.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::icm20689 {
// Register map
static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75;
static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B;
static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A;
static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B;
static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C;
static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B;
static constexpr uint8_t ICM20689_READ_BIT = 0x80;
static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98;
static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20)
static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock)
static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4
// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG
enum ICM20689AccelRange : uint8_t {
ICM20689_ACCEL_RANGE_2G = 0x00,
ICM20689_ACCEL_RANGE_4G = 0x01,
ICM20689_ACCEL_RANGE_8G = 0x02,
ICM20689_ACCEL_RANGE_16G = 0x03,
};
// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG
enum ICM20689GyroRange : uint8_t {
ICM20689_GYRO_RANGE_250 = 0x00,
ICM20689_GYRO_RANGE_500 = 0x01,
ICM20689_GYRO_RANGE_1000 = 0x02,
ICM20689_GYRO_RANGE_2000 = 0x03,
};
class ICM20689Component final : public motion::MotionComponent,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> {
public:
void setup() override;
void dump_config() override;
void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; }
void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; }
template<typename F> void add_temperature_listener(F &&callback) {
this->temperature_callback_.add(std::forward<F>(callback));
}
protected:
bool update_data(motion::MotionData &data) override;
void write_register_(uint8_t reg, uint8_t value);
// Reads len bytes starting at reg in one CS-low transaction.
void read_registers_(uint8_t reg, uint8_t *data, size_t len);
ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G};
ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250};
LazyCallbackManager<void(float)> temperature_callback_{};
};
} // namespace esphome::icm20689
+56
View File
@@ -0,0 +1,56 @@
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE
from esphome.components.motion import motion_schema, new_motion_component
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import ICM20689Component, icm20689_ns
DEPENDENCIES = ["spi"]
# Enum proxies (must match the C++ enum values exactly)
ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange")
ACCEL_RANGE_OPTIONS = {
"2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G,
"4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G,
"8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G,
"16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G,
}
ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange")
GYRO_RANGE_OPTIONS = {
"250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250,
"500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500,
"1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000,
"2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000,
}
# Top-level CONFIG_SCHEMA
CONFIG_SCHEMA = (
motion_schema(ICM20689Component, has_accel=True, has_gyro=True)
.extend(
{
cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum(
ACCEL_RANGE_OPTIONS, upper=True
),
cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum(
GYRO_RANGE_OPTIONS, upper=True
),
}
)
.extend(
spi.spi_device_schema(
cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz"
)
)
)
# Code generation
async def to_code(config: ConfigType) -> None:
var = await new_motion_component(config)
await spi.register_spi_device(var, config)
cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE]))
cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE]))
+42
View File
@@ -0,0 +1,42 @@
# YAML config keys
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
DEVICE_CLASS_TEMPERATURE,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import CONF_ICM20689_ID, ICM20689Component
AUTO_LOAD = ["icm20689"]
CONFIG_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_TEMPERATURE,
).extend(
{
cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component),
}
)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
parent = await cg.get_variable(config[CONF_ICM20689_ID])
data = MockObj("data")
value_lambda = await cg.process_lambda(
var.publish_state(data),
[(cg.float_, str(data))],
)
cg.add(parent.add_temperature_listener(value_lambda))
+73 -19
View File
@@ -145,13 +145,42 @@ def is_alpha_only(image: Image):
return all(b.getextrema()[1] == 0 for b in image.split()[:-1])
class ImageBinary(ImageEncoder):
class BitPackedEncoder(ImageEncoder):
"""Packs pixels MSB first, with every row padded to a whole byte."""
def __init__(
self,
bpp: int,
width: int,
height: int,
transparency: str,
dither: Image.Dither,
invert_alpha: bool,
):
self.bpp = bpp
super().__init__(
(width * bpp + 7) // 8, height, transparency, dither, invert_alpha
)
self.bitno = 0
def add_bit(self, bit: bool) -> None:
if bit:
self.data[self.index] |= 0x80 >> self.bitno
self.bitno += 1
if self.bitno == 8:
self.end_row()
def end_row(self) -> None:
if self.bitno != 0:
self.bitno = 0
self.index += 1
class ImageBinary(BitPackedEncoder):
allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY}
def __init__(self, width, height, transparency, dither, invert_alpha):
self.width8 = (width + 7) // 8
super().__init__(self.width8, height, transparency, dither, invert_alpha)
self.bitno = 0
super().__init__(1, width, height, transparency, dither, invert_alpha)
def convert(self, image, path):
if is_alpha_only(image):
@@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder):
return image.convert("1", dither=self.dither)
def encode(self, pixel):
if self.invert_alpha:
pixel = not pixel
if pixel:
self.data[self.index] |= 0x80 >> (self.bitno % 8)
self.bitno += 1
if self.bitno == 8:
self.bitno = 0
self.index += 1
self.add_bit(bool(pixel) != self.invert_alpha)
def end_row(self):
"""
Pad rows to a byte boundary
"""
if self.bitno != 0:
self.bitno = 0
self.index += 1
# The eight colours one bit per channel can express, for dithering.
_RGB111_PALETTE = Image.new("P", (1, 1))
_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"])
class ImageRGB111(BitPackedEncoder):
allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL}
def __init__(
self,
width: int,
height: int,
transparency: str,
dither: Image.Dither,
invert_alpha: bool,
):
bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3
super().__init__(bpp, width, height, transparency, dither, invert_alpha)
def convert(self, image: Image, path: str):
image = image.convert("RGBA")
if self.dither != Image.Dither.FLOYDSTEINBERG:
return image
alpha = image.getchannel("A")
image = image.convert("RGB").quantize(
palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG
)
image = image.convert("RGB")
image.putalpha(alpha)
return image
def encode(self, pixel: tuple[int, int, int, int]):
r, g, b, a = pixel
if self.invert_alpha:
a ^= 0xFF
for channel in (r, g, b, a)[: self.bpp]:
self.add_bit(channel > 127)
class ImageGrayscale(ImageEncoder):
@@ -315,6 +368,7 @@ class ReplaceWith:
IMAGE_TYPE = {
"BINARY": ImageBinary,
"GRAYSCALE": ImageGrayscale,
"RGB111": ImageRGB111,
"RGB565": ImageRGB565,
"RGB": ImageRGB,
"TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"),
+35
View File
@@ -3,6 +3,8 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
ESPHOME_LOG_TAG(TAG, "image");
namespace esphome::image {
void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
@@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
}
break;
}
case IMAGE_TYPE_RGB111: {
for (int img_x = img_x0; img_x < w; img_x++) {
for (int img_y = img_y0; img_y < h; img_y++) {
auto color = this->get_rgb111_pixel_(img_x, img_y);
if (color.w >= 0x80) {
display->draw_pixel_at(x + img_x, y + img_y, color);
}
}
}
break;
}
case IMAGE_TYPE_GRAYSCALE:
for (int img_x = img_x0; img_x < w; img_x++) {
for (int img_y = img_y0; img_y < h; img_y++) {
@@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
if (this->get_binary_pixel_(x, y))
return color_on;
return color_off;
case IMAGE_TYPE_RGB111:
return this->get_rgb111_pixel_(x, y);
case IMAGE_TYPE_GRAYSCALE:
return this->get_grayscale_pixel_(x, y);
case IMAGE_TYPE_RGB565:
@@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() {
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
break;
case IMAGE_TYPE_RGB111:
// Rejected by the lvgl config validation
ESP_LOGE(TAG, "RGB111 images are not supported in LVGL.");
break;
case IMAGE_TYPE_RGB:
switch (this->transparency_) {
case TRANSPARENCY_ALPHA_CHANNEL:
@@ -152,6 +171,19 @@ bool Image::get_binary_pixel_(int x, int y) const {
const uint32_t pos = x + y * width_8;
return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u));
}
Color Image::get_rgb111_pixel_(int x, int y) const {
const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_;
const uint8_t *byte = this->data_start_ + bitpos / 8;
const uint8_t shift = bitpos % 8;
// A pixel can straddle two bytes; read the second one only when it does.
uint16_t window = progmem_read_byte(byte) << 8;
if (shift + this->bpp_ > 8)
window |= progmem_read_byte(byte + 1);
const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1);
const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits;
const bool opaque = this->bpp_ == 3 || (bits & 1);
return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00);
}
Color Image::get_rgb_pixel_(int x, int y) const {
const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8;
Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1),
@@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T
case IMAGE_TYPE_BINARY:
this->bpp_ = 1;
break;
case IMAGE_TYPE_RGB111:
this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3;
break;
case IMAGE_TYPE_GRAYSCALE:
this->bpp_ = 8;
break;
+2
View File
@@ -13,6 +13,7 @@ enum ImageType {
IMAGE_TYPE_GRAYSCALE = 1,
IMAGE_TYPE_RGB = 2,
IMAGE_TYPE_RGB565 = 3,
IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper
};
enum Transparency {
@@ -44,6 +45,7 @@ class Image : public display::BaseImage {
#endif
protected:
bool get_binary_pixel_(int x, int y) const;
Color get_rgb111_pixel_(int x, int y) const;
Color get_rgb_pixel_(int x, int y) const;
Color get_rgb565_pixel_(int x, int y) const;
Color get_grayscale_pixel_(int x, int y) const;
+17
View File
@@ -25,6 +25,7 @@ from esphome.components.image import (
ImageBinary,
ImageGrayscale,
ImageRGB,
ImageRGB111,
ImageRGB565,
get_image_metadata,
)
@@ -43,6 +44,7 @@ from esphome.const import (
CONF_ROTATION,
CONF_TIMEOUT,
CONF_TRIGGER_ID,
CONF_TYPE,
)
from esphome.core import CORE, ID, Lambda
from esphome.cpp_generator import MockObj
@@ -293,6 +295,21 @@ def final_validation(config_list):
if len(config_list) != 1:
multi_conf_validate(config_list)
for img in get_lv_images_used():
path = global_config.get_path_for_id(img.id)[:-1]
# An image nested in another platform (sendspin's current_image) takes its type from the parent
while path:
image_conf = global_config.get_config_for_path(path)
if isinstance(image_conf, dict) and CONF_TYPE in image_conf:
break
path = path[:-1]
else:
continue
if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111:
raise cv.FinalExternalInvalid(
f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead",
path + [CONF_TYPE],
)
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
+8 -8
View File
@@ -85,7 +85,7 @@ class LvColorPickerType : public LvCompound {
if (left != nullptr) {
lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN);
// A narrow hue column may be slimmer than its label.
lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
lv_obj_set_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true);
}
// The text rows keep their natural height and the bars between them take what is left,
@@ -233,7 +233,7 @@ class LvColorPickerType : public LvCompound {
static lv_obj_t *create_box(lv_obj_t *parent) {
auto *obj = lv_obj_create(parent);
lv_obj_add_style(obj, &plain_box, LV_PART_MAIN);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flag(obj, LV_OBJ_FLAG_SCROLLABLE, false);
return obj;
}
@@ -326,7 +326,7 @@ class LvColorPickerType : public LvCompound {
lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN);
lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flag(disc, LV_OBJ_FLAG_SCROLLABLE, false);
return disc;
}
@@ -336,7 +336,7 @@ class LvColorPickerType : public LvCompound {
this->wheel_ = create_disc(container, &wheel_hue_grad);
// Not clickable, so touches fall through to the wheel underneath.
auto *overlay = create_disc(this->wheel_, &wheel_white_grad);
lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_flag(overlay, LV_OBJ_FLAG_CLICKABLE, false);
this->wheel_handle_ = lv_obj_create(this->wheel_);
lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE);
@@ -345,8 +345,8 @@ class LvColorPickerType : public LvCompound {
lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN);
lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN);
lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE, false);
lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE, false);
// PRESSED as well, so a tap takes effect without the finger moving.
lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this);
@@ -359,7 +359,7 @@ class LvColorPickerType : public LvCompound {
void add_value_label_(SliderIndex index, lv_color_t color) {
auto *slider = this->sliders_[index];
lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
lv_obj_set_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true);
auto *label = lv_label_create(slider);
this->values_[index] = label;
lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN);
@@ -509,7 +509,7 @@ class LvColorPickerType : public LvCompound {
static void bubble_events(lv_obj_t *obj) {
for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) {
auto *child = lv_obj_get_child(obj, i);
lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE);
lv_obj_set_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE, true);
bubble_events(child);
}
}
+68
View File
@@ -1,3 +1,4 @@
import re
from typing import Any
from esphome import automation
@@ -44,9 +45,12 @@ from esphome.core import (
coroutine_with_priority,
)
import esphome.final_validate as fv
from esphome.helpers import write_file_if_changed
from esphome.types import ConfigType
from esphome.writer import clean_build
from .const import (
CONF_CORE_OPTIONS,
CONF_DEVICE_TYPE,
CONF_EXT_PAN_ID,
CONF_FORCE_DATASET,
@@ -59,6 +63,7 @@ from .const import (
CONF_PSKC,
CONF_SRP_ID,
CONF_TLV,
OPENTHREAD_CONFIG_PREFIX,
)
CODEOWNERS = ["@mrene"]
@@ -84,6 +89,10 @@ CONF_DEVICE_TYPES = [
"MTD",
]
OT_HEADER_DIR = "openthread"
OT_HEADER_FILE = "esphome_ot_config.h"
OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+")
def _validate_txpower(value: Any) -> int | float:
if CORE.is_esp32:
@@ -158,6 +167,31 @@ def set_sdkconfig_options(config: ConfigType) -> None:
add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True)
def _write_custom_header(config: ConfigType) -> None:
"""Write core_options to the OpenThread custom header."""
if not (options := config.get(CONF_CORE_OPTIONS)):
return
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True)
# IDF resolves the path relative to the project (build) directory.
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR)
add_idf_sdkconfig_option(
"CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE
)
content = (
"// Auto-generated by ESPHome from openthread core_options - do not edit\n"
"#pragma once\n\n"
+ "".join(
f"#define {name} {value}\n" for name, value in sorted(options.items())
)
)
# PlatformIO does not track this header as an OpenThread input; the esp-idf
# toolchain does.
header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE)
if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf:
clean_build(clear_pio_cache=False)
openthread_ns = cg.esphome_ns.namespace("openthread")
OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component)
OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component)
@@ -220,6 +254,34 @@ def _validate_platform(config: ConfigType) -> ConfigType:
)(config)
def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]:
for name in options:
if (
not name.startswith(OPENTHREAD_CONFIG_PREFIX)
or name == OPENTHREAD_CONFIG_PREFIX
):
raise cv.Invalid(
f"'{name}' is not an OpenThread core option; names must be "
f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name",
path=[name],
)
if not OT_CORE_OPTION_NAME.fullmatch(name):
raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name])
return options
def _validate_core_option_value(value: Any) -> str:
value = cv.string_strict(value)
if not value.strip():
raise cv.Invalid("OpenThread core option value must not be empty")
if "\n" in value or "\r" in value:
raise cv.Invalid("OpenThread core option value must be a single line")
# GCC treats a backslash followed by spaces as a line continuation too.
if value.rstrip().endswith("\\"):
raise cv.Invalid("OpenThread core option value must not end with \\")
return value
def _validate_tlv_hex(value: Any) -> str:
s = cv.string_strict(value)
if len(s) % 2 != 0:
@@ -252,6 +314,11 @@ CONFIG_SCHEMA = cv.All(
_validate_txpower,
),
cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds,
cv.Optional(CONF_CORE_OPTIONS): cv.All(
cv.only_on_esp32,
{cv.string_strict: _validate_core_option_value},
_validate_core_option_names,
),
}
).extend(_CONNECTION_SCHEMA),
cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV),
@@ -325,6 +392,7 @@ async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
set_sdkconfig_options(config)
_write_custom_header(config)
elif CORE.using_zephyr:
# Crypto through PSA: mbedtls, plus Oberon from SDK 2.7
include_west_project("mbedtls")
+3
View File
@@ -1,3 +1,4 @@
CONF_CORE_OPTIONS = "core_options"
CONF_DEVICE_TYPE = "device_type"
CONF_EXT_PAN_ID = "ext_pan_id"
CONF_FORCE_DATASET = "force_dataset"
@@ -10,3 +11,5 @@ CONF_POLL_PERIOD = "poll_period"
CONF_PSKC = "pskc"
CONF_SRP_ID = "srp_id"
CONF_TLV = "tlv"
OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_"
+6
View File
@@ -10,6 +10,7 @@ from esphome.const import (
CONF_MODE,
CONF_NUMBER,
CONF_OUTPUT,
CONF_RESET,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
@@ -26,11 +27,14 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin)
CONF_PCF8574 = "pcf8574"
CONF_PCF8575 = "pcf8575"
# Keep in sync with PCF8574Component::reset_ in pcf8574.h.
DEFAULT_RESET = True
CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
cv.Optional(CONF_PCF8575, default=False): cv.boolean,
cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
@@ -44,6 +48,8 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_pcf8575(config[CONF_PCF8575]))
if (reset := config[CONF_RESET]) != DEFAULT_RESET:
cg.add(var.set_reset(reset))
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
+18 -15
View File
@@ -12,8 +12,13 @@ void PCF8574Component::setup() {
return;
}
this->write_gpio_();
this->read_gpio_();
if (this->reset_) {
this->write_gpio_();
this->read_gpio_();
} else {
this->output_mask_ = this->input_mask_;
this->mode_mask_ = ~this->configured_input_pins_;
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
@@ -40,8 +45,9 @@ void PCF8574Component::loop() {
void PCF8574Component::dump_config() {
ESP_LOGCONFIG(TAG,
"PCF8574:\n"
" Is PCF8575: %s",
YESNO(this->pcf8575_));
" Is PCF8575: %s\n"
" Reset: %s",
YESNO(this->pcf8575_), YESNO(this->reset_));
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_I2C_DEVICE(this)
if (this->is_failed()) {
@@ -82,20 +88,12 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) {
bool PCF8574Component::read_gpio_() {
if (this->is_failed())
return false;
bool success;
uint8_t data[2];
if (this->pcf8575_) {
success = this->read_bytes_raw(data, 2);
this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0);
} else {
success = this->read_bytes_raw(data, 1);
this->input_mask_ = data[0];
}
if (!success) {
uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read.
if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) {
this->status_set_warning();
return false;
}
this->input_mask_ = (uint16_t(data[1]) << 8) | data[0];
this->status_clear_warning();
return true;
}
@@ -123,6 +121,11 @@ bool PCF8574Component::write_gpio_() {
float PCF8574Component::get_setup_priority() const { return setup_priority::IO; }
void PCF8574GPIOPin::setup() { pin_mode(flags_); }
void PCF8574GPIOPin::set_flags(gpio::Flags flags) {
this->flags_ = flags;
if (flags & gpio::FLAG_INPUT)
this->parent_->register_input_pin(this->pin_);
}
void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); }
bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; }
void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); }
+10 -1
View File
@@ -16,6 +16,9 @@ class PCF8574Component final : public Component,
PCF8574Component() = default;
void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; }
void set_reset(bool reset) { this->reset_ = reset; }
// Called by pin setters before any component's setup or GPIO writes.
void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; }
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
/// Check i2c availability and setup masks
@@ -44,6 +47,11 @@ class PCF8574Component final : public Component,
uint16_t output_mask_{0x00};
/// The state read in read_gpio_ - 1 means HIGH, 0 means LOW
uint16_t input_mask_{0x00};
// Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins
// (including undeclared pins) are treated as outputs seeded from their observed levels.
uint16_t configured_input_pins_{0x00};
// Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default.
bool reset_{true};
bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574
InternalGPIOPin *interrupt_pin_{nullptr};
};
@@ -63,7 +71,8 @@ class PCF8574GPIOPin final : public GPIOPin {
void set_parent(PCF8574Component *parent) { parent_ = parent; }
void set_pin(uint8_t pin) { pin_ = pin; }
void set_inverted(bool inverted) { inverted_ = inverted; }
void set_flags(gpio::Flags flags) { flags_ = flags; }
// Call after set_parent() and set_pin(): input flags register the pin with the parent.
void set_flags(gpio::Flags flags);
gpio::Flags get_flags() const override { return this->flags_; }
@@ -99,6 +99,22 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
}
break;
}
case image::IMAGE_TYPE_RGB111: {
uint8_t bits = (color.r >> 7) << 2 | (color.g >> 7) << 1 | (color.b >> 7);
if (this->bpp_ == 4)
bits = bits << 1 | (color.w >> 7);
const uint32_t stride = (this->buffer_width_ * this->bpp_ + 7u) / 8u;
const uint32_t bitpos = y * stride * 8 + x * this->bpp_;
uint8_t *byte = this->buffer_ + bitpos / 8;
// Place the pixel in a 16-bit window so it may straddle two bytes.
const uint8_t shift = 16 - this->bpp_ - bitpos % 8;
const uint16_t mask = ((1u << this->bpp_) - 1) << shift;
const uint16_t value = bits << shift;
byte[0] = (byte[0] & ~(mask >> 8)) | (value >> 8);
if (mask & 0xFF)
byte[1] = (byte[1] & ~(mask & 0xFF)) | (value & 0xFF);
break;
}
case image::IMAGE_TYPE_GRAYSCALE: {
uint32_t pos = this->get_position_(x, y);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
+7 -3
View File
@@ -295,8 +295,11 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
}
void loop() override {
if (this->num_running_ == 0)
if (this->num_running_ == 0) {
// Nothing left to wait for, e.g. once the last queued item has run
this->disable_loop();
return;
}
if (this->script_->is_running())
return;
@@ -305,9 +308,10 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
// Processing all items in a while loop causes infinite loops because
// play_next_() can trigger more items to be queued
if (!this->param_queue_.empty()) {
auto &params = this->param_queue_.front();
std::apply([this](auto &...args) { this->play_next_(args...); }, params);
// Take the item out first: the chain may call stop(), which clears the queue
auto params = std::move(this->param_queue_.front());
this->param_queue_.pop_front();
std::apply([this](auto &...args) { this->play_next_(args...); }, params);
} else {
// Queue is now empty - disable loop until next play_complex
this->disable_loop();
+28 -21
View File
@@ -4,7 +4,6 @@ from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_B_CONSTANT
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
@@ -47,7 +46,6 @@ from esphome.const import (
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE,
CONF_WEB_SERVER,
CONF_WINDOW_SIZE,
DEVICE_CLASS_ABSOLUTE_HUMIDITY,
DEVICE_CLASS_APPARENT_POWER,
@@ -113,11 +111,20 @@ from esphome.const import (
DEVICE_CLASS_WIND_SPEED,
ENTITY_CATEGORY_CONFIG,
)
from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority
from esphome.core import (
CORE,
ID,
CoroPriority,
Lambda,
coroutine_with_priority,
entity_helpers,
)
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
mqtt_component_class,
queue_entity_register,
setup_device_class,
setup_entity,
@@ -327,12 +334,14 @@ validate_icon = cv.icon
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
_SENSOR_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMPONENT_SCHEMA)
.extend(zigbee.SENSOR_SCHEMA)
.extend(entity_helpers.ZIGBEE_SENSOR_SCHEMA)
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt_component_class("MQTTSensorComponent")
),
cv.GenerateID(): cv.declare_id(Sensor),
cv.Optional(
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
@@ -374,7 +383,7 @@ _SENSOR_SCHEMA = (
)
_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor"))
_SENSOR_SCHEMA.add_extra(zigbee.validate_sensor)
_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_sensor"))
def sensor_schema(
@@ -1061,22 +1070,20 @@ async def setup_sensor_core_(var, config):
CORE.add_job(_build_sensor_automations, var, config)
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
mqtt_ = await entity_helpers.setup_entity_integrations(var, config)
if mqtt_ is not None and (
(expire_after := config.get(CONF_EXPIRE_AFTER, cv.UNDEFINED))
is not cv.UNDEFINED
):
if expire_after is None:
cg.add(mqtt_.disable_expire_after())
else:
cg.add(mqtt_.set_expire_after(expire_after))
if (
expire_after := config.get(CONF_EXPIRE_AFTER, cv.UNDEFINED)
) is not cv.UNDEFINED:
if expire_after is None:
cg.add(mqtt_.disable_expire_after())
else:
cg.add(mqtt_.set_expire_after(expire_after))
if "zigbee" in CORE.loaded_integrations:
from esphome.components import zigbee
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
await zigbee.setup_sensor(var, config)
await zigbee.setup_sensor(var, config)
async def register_sensor(var, config):
+44
View File
@@ -1,10 +1,54 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import tmc22xx
import esphome.config_validation as cv
from esphome.const import CONF_THRESHOLD
CODEOWNERS = ["@remcom"]
DOMAIN = "tmc2209"
CONF_SEDN = "sedn"
CONF_SEIMIN = "seimin"
CONF_SEMAX = "semax"
CONF_SEMIN = "semin"
CONF_SEUP = "seup"
CONF_TCOOL_THRESHOLD = "tcool_threshold"
tmc2209_ns = cg.esphome_ns.namespace("tmc2209")
TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper)
tmc22xx.register_actions("tmc2209", TMC2209Stepper)
automation.register_apply_action(
"tmc2209.stallguard",
cv.Schema(
{
cv.GenerateID(): cv.use_id(TMC2209Stepper),
cv.Optional(CONF_THRESHOLD): cv.templatable(cv.uint8_t),
cv.Optional(CONF_TCOOL_THRESHOLD): cv.templatable(
cv.int_range(0, 2**20 - 1)
),
}
),
automation.ApplyField(CONF_THRESHOLD, "set_stallguard_threshold", cg.uint8),
automation.ApplyField(CONF_TCOOL_THRESHOLD, "set_tcool_threshold", cg.uint32),
)
automation.register_apply_action(
"tmc2209.coolconf",
cv.Schema(
{
cv.GenerateID(): cv.use_id(TMC2209Stepper),
cv.Optional(CONF_SEMIN): cv.templatable(cv.int_range(0, 15)),
cv.Optional(CONF_SEMAX): cv.templatable(cv.int_range(0, 15)),
cv.Optional(CONF_SEUP): cv.templatable(cv.int_range(0, 3)),
cv.Optional(CONF_SEDN): cv.templatable(cv.int_range(0, 3)),
cv.Optional(CONF_SEIMIN): cv.templatable(cv.boolean),
}
),
automation.ApplyField(CONF_SEMIN, "set_semin", cg.uint8),
automation.ApplyField(CONF_SEMAX, "set_semax", cg.uint8),
automation.ApplyField(CONF_SEUP, "set_seup", cg.uint8),
automation.ApplyField(CONF_SEDN, "set_sedn", cg.uint8),
automation.ApplyField(CONF_SEIMIN, "set_seimin", cg.bool_),
)
+26 -2
View File
@@ -1,4 +1,6 @@
from esphome import automation
from esphome.components import tmc22xx
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import TMC2209Stepper
@@ -6,9 +8,31 @@ from . import TMC2209Stepper
AUTO_LOAD = ["tmc22xx"]
DEPENDENCIES = ["uart"]
CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3)
CONF_ON_STALL = "on_stall"
def _validate_stall_needs_diag(config: ConfigType) -> ConfigType:
if CONF_ON_STALL in config and tmc22xx.CONF_DIAG_PIN not in config:
raise cv.Invalid(
f"'{CONF_ON_STALL}' requires '{tmc22xx.CONF_DIAG_PIN}', stalls are only reported on DIAG"
)
return config
CONFIG_SCHEMA = cv.All(
tmc22xx.tmc22xx_schema(
TMC2209Stepper,
max_address=3,
extra={cv.Optional(CONF_ON_STALL): automation.validate_automation({})},
),
_validate_stall_needs_diag,
)
FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate
async def to_code(config: ConfigType) -> None:
await tmc22xx.new_tmc22xx(config)
var = await tmc22xx.new_tmc22xx(config)
for conf in config.get(CONF_ON_STALL, []):
await automation.build_callback_automation(
var, "add_on_stall_callback", [], conf
)
+20
View File
@@ -10,4 +10,24 @@ void TMC2209Stepper::dump_config() {
TMC22XXStepper::dump_config();
}
uint32_t *TMC2209Stepper::shadow_register(uint8_t reg) {
switch (reg) {
case REG_TCOOLTHRS:
return &this->tcoolthrs_;
case REG_SGTHRS:
return &this->sgthrs_;
case REG_COOLCONF:
return &this->coolconf_;
default:
return TMC22XXStepper::shadow_register(reg);
}
}
void TMC2209Stepper::restore_registers() {
TMC22XXStepper::restore_registers();
this->write_register(REG_TCOOLTHRS, this->tcoolthrs_);
this->write_register(REG_SGTHRS, this->sgthrs_);
this->write_register(REG_COOLCONF, this->coolconf_);
}
} // namespace esphome::tmc2209
+39
View File
@@ -4,12 +4,51 @@
namespace esphome::tmc2209 {
using tmc22xx::RegisterField;
// StallGuard and CoolStep registers, which the TMC2208 does not have
static constexpr uint8_t REG_TCOOLTHRS = 0x14;
static constexpr uint8_t REG_SGTHRS = 0x40;
static constexpr uint8_t REG_COOLCONF = 0x42;
static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false};
static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false};
static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false};
static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false};
static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false};
static constexpr RegisterField SEDN{REG_COOLCONF, 13, 2, false};
static constexpr RegisterField SEIMIN{REG_COOLCONF, 15, 1, false};
class TMC2209Stepper : public tmc22xx::TMC22XXStepper {
public:
void dump_config() override;
/// Called when DIAG reports a stall. StallGuard only works in StealthChop, between TCOOLTHRS and TPWMTHRS.
template<typename F> void add_on_stall_callback(F &&callback) {
this->stall_callback_.add(std::forward<F>(callback));
}
/// A stall is reported when the StallGuard result drops below twice this value.
void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); }
/// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG.
void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); }
void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); }
void set_semax(uint8_t semax) { this->write_field(SEMAX, semax); }
void set_seup(uint8_t seup) { this->write_field(SEUP, seup); }
void set_sedn(uint8_t sedn) { this->write_field(SEDN, sedn); }
void set_seimin(bool seimin) { this->write_field(SEIMIN, seimin); }
protected:
uint8_t expected_version() const override { return 0x21; }
uint32_t *shadow_register(uint8_t reg) override;
void restore_registers() override;
void on_diag_without_error() override { this->stall_callback_.call(); }
LazyCallbackManager<void()> stall_callback_;
// Write-only registers with their power-on values
uint32_t tcoolthrs_{0};
uint32_t sgthrs_{0};
uint32_t coolconf_{0};
};
} // namespace esphome::tmc2209
+17 -1
View File
@@ -21,6 +21,7 @@ DEPENDENCIES = ["uart"]
CONF_ANALOG_CURRENT_SCALE = "analog_current_scale"
CONF_CLOCK_FREQUENCY = "clock_frequency"
CONF_DIAG_PIN = "diag_pin"
CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle"
CONF_ENN_PIN = "enn_pin"
CONF_HOLD_CURRENT = "hold_current"
@@ -31,6 +32,7 @@ CONF_INTERPOLATION = "interpolation"
CONF_INVERSE_DIRECTION = "inverse_direction"
CONF_IRUN = "irun"
CONF_MICROSTEPS = "microsteps"
CONF_ON_STATUS = "on_status"
CONF_OTTRIM = "ottrim"
CONF_RSENSE = "rsense"
CONF_RUN_CURRENT = "run_current"
@@ -44,6 +46,7 @@ TMC22XXStepper = tmc22xx_ns.class_(
"TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice
)
StandstillMode = tmc22xx_ns.enum("StandstillMode")
DriverEvent = tmc22xx_ns.enum("DriverEvent")
STANDSTILL_MODES = {
"normal": StandstillMode.STANDSTILL_MODE_NORMAL,
@@ -56,7 +59,9 @@ validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True)
validate_current = cv.All(cv.current, cv.positive_float)
def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema:
def tmc22xx_schema(
cls: MockObjClass, max_address: int, extra: dict | None = None
) -> cv.Schema:
"""Return the stepper schema for a family member; `max_address` is 0 for chips without address pins."""
return cv.All(
stepper.STEPPER_SCHEMA.extend(
@@ -67,6 +72,7 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema:
cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_DIAG_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All(
cv.frequency, cv.int_range(min=1)
),
@@ -79,6 +85,8 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema:
cv.Optional(CONF_RUN_CURRENT): validate_current,
cv.Optional(CONF_HOLD_CURRENT): validate_current,
cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps,
cv.Optional(CONF_ON_STATUS): automation.validate_automation({}),
**(extra or {}),
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -133,6 +141,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable:
(CONF_STEP_PIN, var.set_step_pin),
(CONF_DIR_PIN, var.set_dir_pin),
(CONF_INDEX_PIN, var.set_index_pin),
(CONF_DIAG_PIN, var.set_diag_pin),
):
if (pin := config.get(key)) is not None:
cg.add(setter(await cg.gpio_pin_expression(pin)))
@@ -145,6 +154,13 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable:
):
if (value := config.get(key)) is not None:
cg.add(setter(value))
for conf in config.get(CONF_ON_STATUS, []):
await automation.build_callback_automation(
var,
"add_on_status_callback",
[(DriverEvent, "event"), (cg.bool_, "active")],
conf,
)
return var
+171 -15
View File
@@ -23,6 +23,49 @@ static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resis
static constexpr float APPROACH_TIME_S = 0.02f;
// Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer
static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16;
static constexpr uint32_t STATUS_POLL_INTERVAL_MS = 1000;
// While the driver does not answer, for example without motor supply, only poll every 10 s; each failed read
// blocks the loop for the UART timeout
static constexpr uint8_t UNREACHABLE_POLL_SKIP = 9;
static constexpr uint32_t GSTAT_FLAGS = 0x07; // reset, drv_err, uv_cp
static constexpr uint32_t DRV_STATUS_FLAGS = 0x0FFF;
static const LogString *driver_event_to_string(DriverEvent event) {
switch (event) {
case DRIVER_EVENT_RESET:
return LOG_STR("Driver reset");
case DRIVER_EVENT_DRIVER_ERROR:
return LOG_STR("Driver error");
case DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE:
return LOG_STR("Charge pump undervoltage");
case DRIVER_EVENT_OVERTEMPERATURE_PREWARNING:
return LOG_STR("Overtemperature prewarning");
case DRIVER_EVENT_OVERTEMPERATURE:
return LOG_STR("Overtemperature shutdown");
case DRIVER_EVENT_SHORT_TO_GROUND_A:
return LOG_STR("Short to ground on phase A");
case DRIVER_EVENT_SHORT_TO_GROUND_B:
return LOG_STR("Short to ground on phase B");
case DRIVER_EVENT_LOW_SIDE_SHORT_A:
return LOG_STR("Low side short on phase A");
case DRIVER_EVENT_LOW_SIDE_SHORT_B:
return LOG_STR("Low side short on phase B");
case DRIVER_EVENT_OPEN_LOAD_A:
return LOG_STR("Open load on phase A");
case DRIVER_EVENT_OPEN_LOAD_B:
return LOG_STR("Open load on phase B");
case DRIVER_EVENT_TEMPERATURE_120C:
return LOG_STR("Temperature above 120C");
case DRIVER_EVENT_TEMPERATURE_143C:
return LOG_STR("Temperature above 143C");
case DRIVER_EVENT_TEMPERATURE_150C:
return LOG_STR("Temperature above 150C");
case DRIVER_EVENT_TEMPERATURE_157C:
return LOG_STR("Temperature above 157C");
default:
return LOG_STR("Unknown");
}
}
// CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first
static uint8_t datagram_crc(const uint8_t *data, size_t len) {
@@ -34,8 +77,19 @@ static uint8_t datagram_crc(const uint8_t *data, size_t len) {
return crc;
}
// Index into RESTORED_REGISTERS, or -1 when the register is not restored after a driver reset
static int restored_index(uint8_t reg) {
for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) {
if (RESTORED_REGISTERS[i] == reg)
return static_cast<int>(i);
}
return -1;
}
void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; }
void IRAM_ATTR DiagPinStore::gpio_intr(DiagPinStore *arg) { arg->triggered = true; }
void TMC22XXStepper::setup() {
if (this->enn_pin_ != nullptr) {
this->enn_pin_->setup();
@@ -53,13 +107,54 @@ void TMC22XXStepper::setup() {
ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_);
}
// Bring write-only registers in line with their cached values and clear the reset flag
this->restore_registers();
this->write_register(REG_GSTAT, GSTAT_FLAGS);
this->apply_config_();
// Currents live in a write-only register, so later changes survive a driver reset through its cached value
if (!std::isnan(this->initial_run_current_))
this->set_run_current(this->initial_run_current_);
if (!std::isnan(this->initial_hold_current_))
this->set_hold_current(this->initial_hold_current_);
if (this->index_pin_ != nullptr) {
this->index_pin_->setup();
this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE);
} else {
this->step_pin_->setup();
this->step_pin_->digital_write(false);
this->dir_pin_->setup();
this->dir_pin_->digital_write(false);
}
if (this->diag_pin_ != nullptr) {
this->diag_pin_->setup();
this->diag_pin_->attach_interrupt(DiagPinStore::gpio_intr, &this->diag_store_, gpio::INTERRUPT_RISING_EDGE);
}
// Polling notices a driver reset, and conditions that clear without a DIAG edge
this->set_interval(STATUS_POLL_INTERVAL_MS, [this]() {
if (this->status_poll_skip_ > 0) {
this->status_poll_skip_--;
return;
}
if (!this->update_status_())
this->status_poll_skip_ = UNREACHABLE_POLL_SKIP;
});
this->set_enabled(true);
}
void TMC22XXStepper::restore_registers() {
this->write_register(REG_IHOLD_IRUN, this->ihold_irun_);
this->write_register(REG_TPOWERDOWN, this->tpowerdown_);
this->write_register(REG_TPWMTHRS, this->tpwmthrs_);
this->write_register(REG_VACTUAL, 0);
this->write_register(REG_GSTAT, 0x07);
for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) {
if ((this->restored_written_ & (1u << i)) != 0)
this->write_register(RESTORED_REGISTERS[i], this->restored_values_[i]);
}
}
void TMC22XXStepper::apply_config_() {
this->write_field(PDN_DISABLE, true);
this->write_field(MSTEP_REG_SELECT, true);
this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_));
@@ -76,28 +171,59 @@ void TMC22XXStepper::setup() {
this->toff_ = *toff;
this->set_microsteps(this->initial_microsteps_);
if (!std::isnan(this->initial_run_current_))
this->set_run_current(this->initial_run_current_);
if (!std::isnan(this->initial_hold_current_))
this->set_hold_current(this->initial_hold_current_);
if (this->index_pin_ != nullptr) {
// INDEX toggles on every step of the internal pulse generator that VACTUAL drives
this->write_field(INDEX_OTPW, false);
this->write_field(INDEX_STEP, true);
this->index_pin_->setup();
this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE);
} else {
// Step on both edges, so every step pin toggle is one step
this->write_field(DEDGE, true);
this->write_field(MULTISTEP_FILT, false);
this->step_pin_->setup();
this->step_pin_->digital_write(false);
this->dir_pin_->setup();
this->dir_pin_->digital_write(false);
}
}
this->set_enabled(true);
bool TMC22XXStepper::update_status_() {
auto gstat = this->read_register(REG_GSTAT);
if (!gstat.has_value())
return false;
uint32_t drv_status = 0;
// DRV_STATUS only feeds on_status and the DIAG check, skip the transfer when neither is used
if (this->diag_pin_ != nullptr || !this->status_callback_.empty()) {
auto value = this->read_register(REG_DRV_STATUS);
if (!value.has_value())
return false;
drv_status = *value;
}
// GSTAT flags stay set until written with 1; they are set again while the condition lasts
if ((*gstat & GSTAT_FLAGS) != 0)
this->write_register(REG_GSTAT, *gstat & GSTAT_FLAGS);
uint16_t status = (*gstat & GSTAT_FLAGS) | ((drv_status & DRV_STATUS_FLAGS) << 3);
// Restore before reporting, so an on_status automation for the reset sees and can change the restored settings
if ((status & (1u << DRIVER_EVENT_RESET)) != 0) {
ESP_LOGW(TAG, "Restoring settings after driver reset");
this->restore_registers();
// The motor stopped with the reset, ramp up again from standstill
this->current_speed_ = 0.0f;
this->set_enabled(this->enabled_);
}
uint16_t changed = status ^ this->status_;
this->status_ = status;
for (uint8_t event = 0; event < DRIVER_EVENT_COUNT; event++) {
if ((changed & (1u << event)) == 0)
continue;
bool active = (status & (1u << event)) != 0;
// Open load and temperature levels are informational, the driver keeps running
if (active && event < DRIVER_EVENT_OPEN_LOAD_A) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(driver_event_to_string(static_cast<DriverEvent>(event))));
} else {
ESP_LOGD(TAG, "%s: %s", LOG_STR_ARG(driver_event_to_string(static_cast<DriverEvent>(event))),
active ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("cleared"));
}
this->status_callback_.call(static_cast<DriverEvent>(event), active);
}
return true;
}
void TMC22XXStepper::dump_config() {
@@ -128,6 +254,7 @@ void TMC22XXStepper::dump_config() {
LOG_PIN(" STEP Pin: ", this->step_pin_);
LOG_PIN(" DIR Pin: ", this->dir_pin_);
LOG_PIN(" INDEX Pin: ", this->index_pin_);
LOG_PIN(" DIAG Pin: ", this->diag_pin_);
LOG_STEPPER(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
@@ -137,12 +264,29 @@ void TMC22XXStepper::dump_config() {
void TMC22XXStepper::loop() {
if (this->index_pin_ != nullptr)
this->add_index_pulses_();
if (this->diag_store_.triggered) {
this->diag_store_.triggered = false;
// DIAG rises on a driver error, any other rise is chip specific
if (!this->update_status_()) {
ESP_LOGW(TAG, "DIAG rose, but the driver status could not be read");
} else if ((this->status_ & GSTAT_FLAGS) == 0) {
this->on_diag_without_error();
}
}
if (this->has_reached_target()) {
this->high_freq_.stop();
this->moving_ = false;
} else {
this->high_freq_.start();
if (!this->enabled_)
this->set_enabled(true);
// An unreachable driver is polled only every 10 s. Read it before the first step, so settings lost when the
// motor supply dropped are restored before the move runs on the chip defaults.
if (!this->moving_ && this->status_poll_skip_ > 0) {
this->status_poll_skip_ = this->update_status_() ? 0 : UNREACHABLE_POLL_SKIP;
}
this->moving_ = true;
}
if (this->index_pin_ != nullptr) {
@@ -311,8 +455,12 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) {
}
this->status_clear_warning();
if (uint32_t *shadow = this->shadow_register(reg))
if (uint32_t *shadow = this->shadow_register(reg)) {
*shadow = value;
} else if (int index = restored_index(reg); index >= 0) {
this->restored_values_[index] = value;
this->restored_written_ |= 1u << index;
}
return true;
}
@@ -346,7 +494,15 @@ bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) {
// Skip the UART timeout when the driver never answered during setup
if (this->is_failed())
return false;
auto data = this->read_register(field.reg);
optional<uint32_t> data;
int index = restored_index(field.reg);
if (index >= 0 && (this->restored_written_ & (1u << index)) != 0) {
// Modify the last written value, not the chip: after a driver reset that the poll has not seen yet, the chip
// holds its defaults, and those would replace the values to restore
data = this->restored_values_[index];
} else {
data = this->read_register(field.reg);
}
if (!data.has_value())
return false;
uint32_t mask = field.mask();
+55
View File
@@ -1,5 +1,6 @@
#pragma once
#include <array>
#include <cmath>
#include "esphome/components/stepper/stepper.h"
@@ -30,8 +31,12 @@ static constexpr uint8_t REG_TPOWERDOWN = 0x11;
static constexpr uint8_t REG_TPWMTHRS = 0x13;
static constexpr uint8_t REG_VACTUAL = 0x22;
static constexpr uint8_t REG_CHOPCONF = 0x6C;
static constexpr uint8_t REG_DRV_STATUS = 0x6F;
static constexpr uint8_t REG_PWMCONF = 0x70;
// Readable registers that a driver reset sets back to their defaults; the last written values are restored
static constexpr std::array<uint8_t, 4> RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF};
static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false};
static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false};
static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false};
@@ -63,6 +68,33 @@ enum StandstillMode : uint8_t {
STANDSTILL_MODE_COIL_SHORT_HS = 3,
};
/// Driver conditions reported to on_status, in the order of their status bits: GSTAT bits 0-2, then DRV_STATUS bits
/// 0-11.
enum DriverEvent : uint8_t {
DRIVER_EVENT_RESET = 0,
DRIVER_EVENT_DRIVER_ERROR,
DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE,
DRIVER_EVENT_OVERTEMPERATURE_PREWARNING,
DRIVER_EVENT_OVERTEMPERATURE,
DRIVER_EVENT_SHORT_TO_GROUND_A,
DRIVER_EVENT_SHORT_TO_GROUND_B,
DRIVER_EVENT_LOW_SIDE_SHORT_A,
DRIVER_EVENT_LOW_SIDE_SHORT_B,
DRIVER_EVENT_OPEN_LOAD_A,
DRIVER_EVENT_OPEN_LOAD_B,
DRIVER_EVENT_TEMPERATURE_120C,
DRIVER_EVENT_TEMPERATURE_143C,
DRIVER_EVENT_TEMPERATURE_150C,
DRIVER_EVENT_TEMPERATURE_157C,
DRIVER_EVENT_COUNT,
};
/// Set by a rising edge on DIAG, which signals a driver error or, on chips that support it, a stall.
struct DiagPinStore {
volatile bool triggered{false};
static void gpio_intr(DiagPinStore *arg);
};
/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL.
struct IndexPulseStore {
volatile int32_t pulses{0};
@@ -84,6 +116,7 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; }
void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; }
void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; }
void set_diag_pin(InternalGPIOPin *pin) { this->diag_pin_ = pin; }
void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; }
void set_rsense(float rsense) { this->rsense_ = rsense; }
void set_vsense(bool vsense) { this->vsense_ = vsense; }
@@ -97,6 +130,11 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
void set_enabled(bool enabled);
bool is_enabled() const { return this->enabled_; }
/// Called with the condition and whether it became active or cleared.
template<typename F> void add_on_status_callback(F &&callback) {
this->status_callback_.add(std::forward<F>(callback));
}
void set_microsteps(uint16_t microsteps);
uint16_t get_microsteps();
void set_interpolation(bool enable) { this->write_field(INTPOL, enable); }
@@ -127,6 +165,15 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
virtual uint8_t expected_version() const = 0;
/// Return the cached value of a write-only register, or nullptr when the register is readable.
virtual uint32_t *shadow_register(uint8_t reg);
/// Write the cached write-only registers and the last written configuration registers to the driver, at setup
/// and after a driver reset.
virtual void restore_registers();
/// Write the settings from the configuration to the readable registers.
void apply_config_();
/// Called when DIAG rises without a driver error.
virtual void on_diag_without_error() {}
/// Read GSTAT and DRV_STATUS and report changes. Returns false when the status could not be read.
bool update_status_();
optional<uint8_t> current_to_scale_(float current);
float full_scale_current_();
int32_t get_vactual_() const { return static_cast<int32_t>(extract_field(this->vactual_reg_, VACTUAL)); }
@@ -139,28 +186,36 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
GPIOPin *step_pin_{nullptr};
GPIOPin *dir_pin_{nullptr};
InternalGPIOPin *index_pin_{nullptr};
InternalGPIOPin *diag_pin_{nullptr};
LazyCallbackManager<void(DriverEvent, bool)> status_callback_;
uint32_t clock_frequency_{12000000};
// Write-only registers shared by all family members, with their power-on values
uint32_t ihold_irun_{0x00071703};
uint32_t tpowerdown_{0x14};
uint32_t tpwmthrs_{0};
uint32_t vactual_reg_{0};
std::array<uint32_t, RESTORED_REGISTERS.size()> restored_values_{};
// NaN while not configured
float rsense_{NAN};
float initial_run_current_{NAN};
float initial_hold_current_{NAN};
IndexPulseStore index_store_{};
uint16_t initial_microsteps_{16};
uint16_t status_{0};
optional<bool> vsense_{};
optional<uint8_t> ottrim_{};
uint8_t address_{0};
uint8_t toff_{3};
uint8_t version_{0};
uint8_t restored_written_{0}; // Bit per RESTORED_REGISTERS entry that has been written
uint8_t status_poll_skip_{0};
int8_t direction_{0};
DiagPinStore diag_store_{};
bool analog_current_scale_{false};
bool enabled_{false};
bool vsense_active_{false};
bool step_state_{false};
bool moving_{false};
HighFrequencyLoopRequester high_freq_;
};
+15 -1
View File
@@ -1,4 +1,4 @@
from esphome import automation
from esphome import automation, core
import esphome.codegen as cg
from esphome.components import media_player, micro_wake_word, microphone, speaker
import esphome.config_validation as cv
@@ -47,6 +47,9 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word"
CONF_WAKE_WORD = "wake_word"
CONF_CONVERSATION_TIMEOUT = "conversation_timeout"
CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout"
# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h.
TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000)
CONF_ON_TIMER_STARTED = "on_timer_started"
CONF_ON_TIMER_UPDATED = "on_timer_updated"
@@ -110,6 +113,13 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_CONVERSATION_TIMEOUT, default="300s"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TTS_PLAYBACK_START_TIMEOUT,
default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT,
): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=core.TimePeriod(milliseconds=1)),
),
cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range(
min=0.0, min_included=False
),
@@ -218,6 +228,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN]))
cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER]))
cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT]))
if (
timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT]
) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT:
cg.add(var.set_tts_playback_start_timeout(timeout))
if CONF_ON_LISTENING in config:
await automation.build_automation(
@@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() {
}
void VoiceAssistant::start_playback_timeout_() {
this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() {
this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() {
this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID);
this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
@@ -193,6 +193,7 @@ class VoiceAssistant final : public Component {
void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; }
void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; }
void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; }
void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; }
void reset_conversation_id();
Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; }
@@ -318,6 +319,8 @@ class VoiceAssistant final : public Component {
float volume_multiplier_;
uint32_t conversation_timeout_;
// Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py.
uint32_t tts_playback_start_timeout_{2000};
Configuration config_{};
+8 -24
View File
@@ -12,7 +12,11 @@ from esphome.components.json import enable_arena
from esphome.components.logger import request_log_listener
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
import esphome.config_validation as cv
from esphome.const import (
# Re-exported so entity components and external components keep importing
# these from web_server; defined outside this package so entity base
# schemas do not need to import it.
from esphome.const import ( # noqa: F401
CONF_AP,
CONF_AUTH,
CONF_COMPRESSION,
@@ -31,6 +35,8 @@ from esphome.const import (
CONF_OTA,
CONF_PASSWORD,
CONF_PORT,
CONF_SORTING_GROUP_ID,
CONF_SORTING_WEIGHT,
CONF_STATIC_IP,
CONF_TYPE,
CONF_USERNAME,
@@ -46,6 +52,7 @@ from esphome.const import (
PLATFORM_RTL87XX,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import WEBSERVER_SORTING_SCHEMA # noqa: F401
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -72,9 +79,7 @@ DOMAIN = "web_server"
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
# Schema default that also matches the C++ initializer in web_server_base.h; codegen
@@ -248,27 +253,6 @@ sorting_group = {
cv.Optional(CONF_SORTING_WEIGHT): cv.float_,
}
WEBSERVER_SORTING_SCHEMA = cv.Schema(
{
# The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
cv.requires_component(DOMAIN),
cv.float_,
),
cv.Optional(CONF_SORTING_GROUP_ID): cv.All(
cv.requires_component(DOMAIN),
cv.use_id(cg.int_),
),
}
)
}
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
+17 -67
View File
@@ -26,11 +26,14 @@ from esphome.const import (
CONF_VERSION,
__version__ as ESPHOME_VERSION,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
import esphome.final_validate as fv
from esphome.types import ConfigType
from .const import (
# CONF_CLUSTER, CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_REPORT,
# CONF_USE_DEVICE_TYPE and REPORT are re-exported for existing consumers of
# this package's namespace.
from .const import ( # noqa: F401
CONF_CLUSTER,
CONF_ENDPOINT,
CONF_MAX_EP_NUMBER,
@@ -62,12 +65,7 @@ from .zigbee_esp32 import (
validate_switch_esp32,
zigbee_require_vfs_select,
)
from .zigbee_zephyr import (
zephyr_binary_sensor,
zephyr_number,
zephyr_sensor,
zephyr_switch,
)
from .zigbee_zephyr import zephyr_number, zephyr_switch
_LOGGER = logging.getLogger(__name__)
@@ -77,68 +75,20 @@ DOMAIN = "zigbee"
CONFLICTS_WITH = ["openthread"]
def _check_report_deprecation(value: str) -> str:
if str(value).lower() == "coordinator":
_LOGGER.warning(
"Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead."
)
return value
def _get_base_schema(cluster_options: list[str] | None = None) -> cv.Schema:
schema = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
cv.requires_component(DOMAIN),
cv.requires_component(ESP32_DOMAIN),
_check_report_deprecation,
cv.enum(REPORT, lower=True),
),
cv.Optional(CONF_ENDPOINT): cv.All(
cv.requires_component(DOMAIN),
cv.requires_component(ESP32_DOMAIN),
cv.int_range(1, CONF_MAX_EP_NUMBER),
),
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
cv.requires_component(DOMAIN),
cv.requires_component(ESP32_DOMAIN),
cv.boolean,
),
}
)
if cluster_options:
schema = cv.Schema(
{
cv.OnlyWith(
CONF_CLUSTER, ["esp32", "zigbee"], default=cluster_options[0]
): cv.All(
cv.requires_component(DOMAIN),
cv.requires_component(ESP32_DOMAIN),
cv.one_of(*cluster_options, lower=True),
),
}
).extend(schema)
return schema
# set BASE_SCHEMA for CI and backwards compatibility
BASE_SCHEMA = _get_base_schema()
BINARY_SENSOR_SCHEMA = (
cv.Schema({})
.extend(_get_base_schema(["generic", "device_class"]))
.extend(zephyr_binary_sensor)
)
SENSOR_SCHEMA = (
cv.Schema({})
.extend(_get_base_schema(["generic", "device_class"]))
.extend(zephyr_sensor)
)
# Defined in esphome.core.entity_helpers so entity base schemas can reference
# them without importing this package; re-exported here for existing consumers.
BASE_SCHEMA = entity_helpers.ZIGBEE_BASE_ENTITY_SCHEMA
BINARY_SENSOR_SCHEMA = entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA
SENSOR_SCHEMA = entity_helpers.ZIGBEE_SENSOR_SCHEMA
SWITCH_SCHEMA = (
cv.Schema({}).extend(_get_base_schema(["generic", "on_off"])).extend(zephyr_switch)
cv.Schema({})
.extend(entity_helpers.zigbee_base_entity_schema(["generic", "on_off"]))
.extend(zephyr_switch)
)
NUMBER_SCHEMA = (
cv.Schema({}).extend(_get_base_schema(["generic"])).extend(zephyr_number)
cv.Schema({})
.extend(entity_helpers.zigbee_base_entity_schema(["generic"]))
.extend(zephyr_number)
)
+13 -14
View File
@@ -1,7 +1,15 @@
from enum import IntEnum
import esphome.codegen as cg
from esphome.const import (
# The entity schema keys and report enum live outside this package so
# entity base schemas can use them without importing it; re-imported here
# so zigbee code keeps its existing import paths.
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
CONF_CLUSTER,
CONF_ENDPOINT,
CONF_REPORT,
CONF_USE_DEVICE_TYPE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
@@ -48,6 +56,10 @@ from esphome.const import (
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.core.entity_helpers import ( # noqa: F401 # pylint: disable=unused-import
ZIGBEE_MAX_EP_NUMBER as CONF_MAX_EP_NUMBER,
ZIGBEE_REPORT as REPORT,
)
zigbee_ns = cg.esphome_ns.namespace("zigbee")
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
@@ -56,24 +68,11 @@ BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
report = zigbee_ns.enum("ZigbeeReportT")
REPORT = {
"coordinator": report.ZIGBEE_REPORT_COORDINATOR,
"enable": report.ZIGBEE_REPORT_ENABLE,
"force": report.ZIGBEE_REPORT_FORCE,
"default": report.ZIGBEE_REPORT_DEFAULT,
}
CONF_CLUSTER = "cluster"
CONF_ENDPOINT = "endpoint"
CONF_MAX_EP_NUMBER = 239
CONF_ON_JOIN = "on_join"
CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_REPORT = "report"
CONF_ROUTER = "router"
CONF_POWER_SOURCE = "power_source"
CONF_SLEEPY = "sleepy"
CONF_USE_DEVICE_TYPE = "use_device_type"
POWER_SOURCE = {
"UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN
"MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE
+4 -3
View File
@@ -1,7 +1,8 @@
# These schema keys live in esphome.const so entity base
# schemas can use them without importing this package.
from esphome.const import CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_SENSOR # noqa: F401 # pylint: disable=unused-import
CONF_MAX_EP_NUMBER_ZEPHYR = 8
CONF_ZIGBEE_ID = "zigbee_id"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
CONF_ZIGBEE_SWITCH = "zigbee_switch"
CONF_ZIGBEE_NUMBER = "zigbee_number"
CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui"
@@ -60,24 +60,6 @@ ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component)
ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component)
ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component)
zephyr_binary_sensor = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
cv.OnlyWith(CONF_ZIGBEE_BINARY_SENSOR, ["nrf52", "zigbee"]): cv.declare_id(
ZigbeeBinarySensor
),
}
)
zephyr_sensor = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
cv.OnlyWith(CONF_ZIGBEE_SENSOR, ["nrf52", "zigbee"]): cv.declare_id(
ZigbeeSensor
),
}
)
zephyr_switch = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
+9
View File
@@ -267,6 +267,7 @@ CONF_CLOCK_RESOLUTION = "clock_resolution"
CONF_CLOSE_ACTION = "close_action"
CONF_CLOSE_DURATION = "close_duration"
CONF_CLOSE_ENDSTOP = "close_endstop"
CONF_CLUSTER = "cluster"
CONF_CO2 = "co2"
CONF_CODE = "code"
CONF_COL = "col"
@@ -405,6 +406,7 @@ CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access"
CONF_ENABLE_RRM = "enable_rrm"
CONF_ENABLE_TIME = "enable_time"
CONF_ENCRYPTION = "encryption"
CONF_ENDPOINT = "endpoint"
CONF_ENERGY = "energy"
CONF_ENTITY_CATEGORY = "entity_category"
CONF_ENTITY_ID = "entity_id"
@@ -903,6 +905,7 @@ CONF_REFERENCE_VOLTAGE = "reference_voltage"
CONF_REFRESH = "refresh"
CONF_RELABEL = "relabel"
CONF_REPEAT = "repeat"
CONF_REPORT = "report"
CONF_REPOSITORY = "repository"
CONF_RESET = "reset"
CONF_RESET_DURATION = "reset_duration"
@@ -980,6 +983,8 @@ CONF_SLEEP_DURATION = "sleep_duration"
CONF_SLEEP_PIN = "sleep_pin"
CONF_SLEEP_WHEN_DONE = "sleep_when_done"
CONF_SONY = "sony"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_SOURCE = "source"
CONF_SOURCE_ID = "source_id"
CONF_SPEAKER = "speaker"
@@ -1111,6 +1116,7 @@ CONF_UPDATE_ON_BOOT = "update_on_boot"
CONF_URL = "url"
CONF_USE_ABBREVIATIONS = "use_abbreviations"
CONF_USE_ADDRESS = "use_address"
CONF_USE_DEVICE_TYPE = "use_device_type"
CONF_USE_DMA = "use_dma"
CONF_USE_FAHRENHEIT = "use_fahrenheit"
CONF_USERNAME = "username"
@@ -1162,6 +1168,9 @@ CONF_Y = "y"
CONF_Y_GRID = "y_grid"
CONF_YEAR = "year"
CONF_ZERO = "zero"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_ID = "zigbee_id"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
TYPE_GIT = "git"
TYPE_LOCAL = "local"
+2
View File
@@ -68,9 +68,11 @@
#define USE_DEVICES
#define USE_DISPLAY
#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1
#define USE_EMONTX_VERSION
#define USE_ENTITY_DEVICE_CLASS
#define USE_ENTITY_ICON
#define USE_ENTITY_UNIT_OF_MEASUREMENT
#define USE_ES8388_SELECT
#define USE_ESP32_BLE_PSRAM
#define USE_ESP32_CAMERA_JPEG_CONVERSION
#define USE_ESP32_HOSTED
+191 -1
View File
@@ -1,21 +1,35 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import functools
from importlib import import_module
from importlib.util import find_spec
import logging
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_CLUSTER,
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_DISABLED_BY_DEFAULT,
CONF_ENDPOINT,
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_ID,
CONF_INTERNAL,
CONF_MQTT_ID,
CONF_NAME,
CONF_REPORT,
CONF_SORTING_GROUP_ID,
CONF_SORTING_WEIGHT,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_DEVICE_TYPE,
CONF_WEB_SERVER,
CONF_WEB_SERVER_ID,
CONF_ZIGBEE_BINARY_SENSOR,
CONF_ZIGBEE_ID,
CONF_ZIGBEE_SENSOR,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.config import (
@@ -23,7 +37,7 @@ from esphome.core.config import (
ICON_MAX_LENGTH,
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
from esphome.cpp_generator import MockObj, MockObjClass, RawStatement, add, get_variable
from esphome.cpp_types import App
import esphome.final_validate as fv
from esphome.helpers import (
@@ -664,3 +678,179 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
return config
return validator
# ---------------------------------------------------------------------------
# Cross-integration entity schema fragments
# ---------------------------------------------------------------------------
#
# Entity base schemas offer mqtt/web_server/zigbee options, but importing
# those packages pulls their full dependency chains (mqtt and zigbee both
# import the esp32 package) into every entity component import. The
# fragments below are built from cheap primitives instead: MockObjClass
# identity is string-based, so the class handles here are interchangeable
# with the ones the integrations declare, and every key is guarded by
# ``cv.requires_component``/``cv.OnlyWith``, which consult
# ``CORE.loaded_integrations`` at validation time without importing. The
# owning integrations re-export the shared schema names and key strings so
# each stays defined once.
_WebServer = cg.esphome_ns.namespace("web_server").class_("WebServer", cg.Component)
WEBSERVER_SORTING_SCHEMA = cv.Schema(
{
# The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(_WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
cv.requires_component("web_server"),
cv.float_,
),
cv.Optional(CONF_SORTING_GROUP_ID): cv.All(
cv.requires_component("web_server"),
cv.use_id(cg.int_),
),
}
)
}
)
_mqtt_ns = cg.esphome_ns.namespace("mqtt")
_MQTTComponent = _mqtt_ns.class_("MQTTComponent", cg.Component)
def mqtt_component_class(name: str) -> MockObjClass:
"""Handle for a per-entity mqtt::<name> companion class."""
return _mqtt_ns.class_(name, _MQTTComponent)
ZIGBEE_MAX_EP_NUMBER = 239
_zigbee_ns = cg.esphome_ns.namespace("zigbee")
_ZigbeeComponent = _zigbee_ns.class_("ZigbeeComponent", cg.Component)
_zigbee_report = _zigbee_ns.enum("ZigbeeReportT")
ZIGBEE_REPORT = {
"coordinator": _zigbee_report.ZIGBEE_REPORT_COORDINATOR,
"enable": _zigbee_report.ZIGBEE_REPORT_ENABLE,
"force": _zigbee_report.ZIGBEE_REPORT_FORCE,
"default": _zigbee_report.ZIGBEE_REPORT_DEFAULT,
}
def _check_report_deprecation(value: str) -> str:
if str(value).lower() == "coordinator":
_LOGGER.warning(
"Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead."
)
return value
def zigbee_base_entity_schema(cluster_options: list[str] | None = None) -> cv.Schema:
"""Zigbee options every entity offers; ``cluster_options`` adds the esp32 cluster key."""
schema = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
_check_report_deprecation,
cv.enum(ZIGBEE_REPORT, lower=True),
),
cv.Optional(CONF_ENDPOINT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.int_range(1, ZIGBEE_MAX_EP_NUMBER),
),
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.boolean,
),
}
)
if cluster_options:
schema = cv.Schema(
{
cv.OnlyWith(
CONF_CLUSTER, ["esp32", "zigbee"], default=cluster_options[0]
): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.one_of(*cluster_options, lower=True),
),
}
).extend(schema)
return schema
ZIGBEE_BASE_ENTITY_SCHEMA = zigbee_base_entity_schema()
# Entity platform -> (config key, zigbee C++ class, esp32 cluster options). A
# unit test checks each class against the owning declaration in zigbee_zephyr.
_ZIGBEE_ENTITY_CLASSES = {
"binary_sensor": (
CONF_ZIGBEE_BINARY_SENSOR,
"ZigbeeBinarySensor",
["generic", "device_class"],
),
"sensor": (CONF_ZIGBEE_SENSOR, "ZigbeeSensor", ["generic", "device_class"]),
}
def _zigbee_entity_schema(platform: str) -> cv.Schema:
conf_key, class_name, cluster_options = _ZIGBEE_ENTITY_CLASSES[platform]
return zigbee_base_entity_schema(cluster_options).extend(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(
_ZigbeeComponent
),
cv.OnlyWith(conf_key, ["nrf52", "zigbee"]): cv.declare_id(
_zigbee_ns.class_(class_name, cg.Component)
),
}
)
ZIGBEE_BINARY_SENSOR_SCHEMA = _zigbee_entity_schema("binary_sensor")
ZIGBEE_SENSOR_SCHEMA = _zigbee_entity_schema("sensor")
def lazy_load_validator(
component: str, name: str
) -> Callable[[ConfigType], ConfigType]:
"""Schema extra delegating to ``components.<component>.<name>`` when loaded."""
if find_spec(f"esphome.components.{component}") is None:
raise ValueError(f"No such component {component!r}")
def validator(config: ConfigType) -> ConfigType:
if component not in CORE.loaded_integrations:
return config
module = import_module(f"esphome.components.{component}")
if (delegate := getattr(module, name, None)) is None:
raise ValueError(f"{component} has no validator {name!r}")
return delegate(config)
return validator
async def setup_entity_integrations(var: MockObj, config: ConfigType) -> MockObj | None:
"""Register the mqtt companion and web_server entry for an entity.
Imports the integrations lazily; returns the mqtt companion (or None)
so callers can apply integration specific options to it.
"""
mqtt_ = None
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
from esphome.components import mqtt
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
if web_server_config := config.get(CONF_WEB_SERVER):
from esphome.components import web_server
await web_server.add_entity_config(var, web_server_config)
return mqtt_
+276 -70
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable
import contextlib
import enum
import gzip
import hashlib
import io
@@ -12,8 +13,10 @@ import socket
import time
from typing import Any
from esphome.const import CONF_ESPHOME, CONF_OTA, CONF_PLATFORM
from esphome.core import EsphomeError
from esphome.helpers import ProgressBar, resolve_ip_address
from esphome.types import ConfigType
OTA_TYPE_UPDATE_APP = 0x00
OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01
@@ -118,6 +121,7 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = {
RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"),
RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"),
}
_AUTH_ANSWERS = (*_AUTH_METHODS, RESPONSE_AUTH_OK)
# Error response code -> human-readable message (without the "Error: " prefix; check_error()
# prepends it uniformly). Looked up by check_error() to translate a single byte from the device
@@ -224,10 +228,52 @@ class OTAKeyRejected(OTAError):
"""The Noise handshake failed on the key; another key may succeed."""
class OTAEncryptionNotOffered(OTAError):
"""A key is configured but the device cannot encrypt."""
class ProbeResult(enum.Enum):
"""What probe_ota_key found; only ACCEPTED means the device runs the key."""
ACCEPTED = "accepted"
REJECTED = "rejected" # the device runs another key
NOT_OFFERED = "not offered" # the device cannot encrypt
DEVICE_ERROR = "device error" # answered, but not about the key
UNREACHABLE = "unreachable" # no answer at all
def __bool__(self) -> bool:
return self is ProbeResult.ACCEPTED
_PROBE_ANSWERS = (
(OTAKeyRejected, ProbeResult.REJECTED),
(OTAEncryptionNotOffered, ProbeResult.NOT_OFFERED),
)
def esphome_ota_items(config: ConfigType) -> list[ConfigType]:
"""The esphome ota entries; a raw ``ota:`` may be a mapping, not a list."""
ota = config.get(CONF_OTA) or []
items = [ota] if isinstance(ota, dict) else ota
return [
item
for item in items
if isinstance(item, dict) and item.get(CONF_PLATFORM) == CONF_ESPHOME
]
# Uploader side options under `ota: encryption:`; the ota component imports
# the names so the upload path never loads the component module
CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload"
CONF_OLD_KEY = "old_key"
ENCRYPTION_NOT_OFFERED_ADVICE = (
"Refusing to send the image in plaintext. The running firmware predates "
"ESPHome 2026.9.0 or has no 'api: encryption: key'. With an api key, install "
"once without the 'ota: encryption:' block (that build offers encryption), "
f"then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' under "
"'ota: encryption:' for this one install, or flash by serial or the "
"web_server OTA platform."
)
ALLOW_PLAINTEXT_UPLOAD_NOTICE = (
f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that "
"migrates a device which never encrypted. If this device encrypted before, "
@@ -429,6 +475,22 @@ def send_check(
raise OTANetworkError(f"sending {msg}: {err}") from err
def noise_handshake(psk: str, prologue: bytes) -> Any:
"""A fresh NNpsk0 initiator; a bad key or a missing noise stack raises here."""
# Lazy: the noise stack is only imported for an encrypted upload
try:
from aioesphomeapi.noise import NoiseHandshake
except ImportError as err:
raise OTAError(
"OTA encryption requires a newer aioesphomeapi; update your "
"esphome installation (pip install -U esphome) and retry"
) from err
try:
return NoiseHandshake(psk, prologue)
except ValueError as err:
raise OTAError(f"Invalid OTA encryption key: {err}") from err
class NoiseSocketWrapper:
"""Runs the OTA session inside a Noise (ChaCha20-Poly1305) transport.
@@ -439,25 +501,12 @@ class NoiseSocketWrapper:
"""
def __init__(self, sock: socket.socket, psk: str, prologue: bytes) -> None:
# Deliberately lazy: the noise stack (noiseprotocol, cryptography) is
# only imported when an encrypted upload actually runs.
try:
from aioesphomeapi.noise import NoiseHandshake
except ImportError as err:
raise OTAError(
"OTA encryption requires a newer aioesphomeapi; update your "
"esphome installation (pip install -U esphome) and retry"
) from err
# The aioesphomeapi import above already loaded cryptography; bind
# the exception once so recv() pays no per-frame import lookup
self._handshake = noise_handshake(psk, prologue)
# Bound once; recv() must not import per frame
from cryptography.exceptions import InvalidTag
self._invalid_tag = InvalidTag
self._sock = sock
try:
self._handshake = NoiseHandshake(psk, prologue)
except ValueError as err:
raise OTAError(f"Invalid OTA encryption key: {err}") from err
self._encrypt = None
self._decrypt = None
self._buffer = b""
@@ -564,43 +613,20 @@ class NoiseSocketWrapper:
return data
def perform_ota(
def _negotiate_session(
sock: socket.socket,
password: str | None,
file_handle: io.IOBase,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
noise_psk: str | None,
plaintext_fallback: bool = False,
allow_plaintext_upload: bool = False,
) -> None:
# Validate up front; an out-of-range value would only surface as a
# ValueError deep inside send_check, bypassing OTAError handling
if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF:
raise OTAError(
f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255"
)
if ota_type not in _SUPPORTED_OTA_TYPES:
supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES))
raise OTAError(
f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}"
)
if noise_psk is not None and not noise_psk:
raise OTAError(
"An empty OTA encryption key was provided; refusing to upload in plaintext"
)
file_contents = file_handle.read()
file_size = len(file_contents)
_LOGGER.info("Uploading %s (%s bytes)", filename, file_size)
) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]:
"""Negotiate through the Noise handshake; returns the socket to go on with
(wrapped once encrypted), version, features, extended."""
# Enable nodelay, we need it for phase 1
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
send_check(sock, MAGIC_BYTES, "magic bytes")
_, version = receive_exactly(sock, 2, "version", RESPONSE_OK)
_LOGGER.info("Connection established; device supports OTA version %s", version)
_LOGGER.debug("Device supports OTA version %s", version)
supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0)
if version not in supported_versions:
raise OTAError(
@@ -656,15 +682,9 @@ def perform_ota(
# capture the image (wifi credentials, api key)
# Remove before 2027.3.0: installing without the block no longer
# falls back then; advise 'allow_plaintext_upload: true' instead
raise OTAError(
raise OTAEncryptionNotOffered(
"An OTA encryption key is configured but the device did not "
"offer encryption; refusing to send the image in plaintext. "
"The running firmware predates ESPHome 2026.9.0 or has no "
"'api: encryption: key'. With an api key, install once "
"without the 'ota: encryption:' block (that build offers "
f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: "
"true' under 'ota: encryption:' for this one install, or flash by "
"serial or the web_server OTA platform."
"offer encryption"
)
if noise_psk:
# The prologue binds every negotiation byte both sides saw, so any
@@ -682,9 +702,50 @@ def perform_ota(
except OTANetworkError as err:
# A transport fault: retry encrypted before considering plaintext
raise OTAHandshakeNetworkError(str(err)) from err
_LOGGER.info("Encrypted connection established")
_LOGGER.debug("Encrypted connection established")
if allow_plaintext_upload:
_LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING)
return sock, version, features, extended_proto
def perform_ota(
sock: socket.socket,
password: str | None,
file_handle: io.IOBase,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
noise_psk: str | None = None,
plaintext_fallback: bool = False,
allow_plaintext_upload: bool = False,
on_upload_start: Callable[[], None] | None = None,
) -> None:
# Validate up front; an out-of-range value would only surface as a
# ValueError deep inside send_check, bypassing OTAError handling
if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF:
raise OTAError(
f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255"
)
if ota_type not in _SUPPORTED_OTA_TYPES:
supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES))
raise OTAError(
f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}"
)
if noise_psk is not None and not noise_psk:
raise OTAError(
"An empty OTA encryption key was provided; refusing to upload in plaintext"
)
file_contents = file_handle.read()
file_size = len(file_contents)
_LOGGER.info("Uploading %s (%s bytes)", filename, file_size)
sock, version, features, extended_proto = _negotiate_session(
sock, noise_psk, plaintext_fallback, allow_plaintext_upload
)
_LOGGER.info("Connection established; device supports OTA version %s", version)
if isinstance(sock, NoiseSocketWrapper):
_LOGGER.info("Encrypted connection established")
if ota_type != OTA_TYPE_UPDATE_APP:
# Any non-app OTA type requires the extended protocol and the
@@ -761,18 +822,15 @@ def perform_ota(
send_check(sock, result, "auth result")
receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK)
(auth,) = receive_exactly(
sock,
1,
"auth",
[RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK],
)
(auth,) = receive_exactly(sock, 1, "auth", list(_AUTH_ANSWERS))
if auth != RESPONSE_AUTH_OK:
hash_func, nonce_size, hash_name = _AUTH_METHODS[auth]
perform_auth(sock, password, hash_func, nonce_size, hash_name)
_LOGGER.info("Handshake complete")
if on_upload_start is not None:
on_upload_start() # the device may commit anything sent from here
sock.settimeout(DATA_PHASE_TIMEOUT)
@@ -890,6 +948,146 @@ def perform_ota(
time.sleep(1)
PROBE_TIMEOUT = 120.0
PROBE_RETRY_DELAY = 3.0
PROBE_CONNECT_TIMEOUT = 3.0
PROBE_HANDSHAKE_TIMEOUT = 10.0
def _resolve_targets(remote_host: str | list[str], remote_port: int) -> list[Any]:
"""Every address to try for the host, through the cache; OTAError when
there is none."""
from esphome.core import CORE
try:
res = resolve_ip_address(
remote_host, remote_port, address_cache=CORE.address_cache
)
except EsphomeError as err:
raise OTAError(err) from err
if not res:
raise OTAError(f"No addresses to connect to for {remote_host}")
return res
class _DeadlineSocket:
"""Re-arms the timeout before every operation so the whole negotiation,
not each read, ends by the deadline."""
def __init__(self, sock: socket.socket, deadline: float) -> None:
self._sock = sock
self._deadline = deadline
def _arm(self) -> None:
remaining = self._deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("probe deadline reached")
self._sock.settimeout(min(PROBE_HANDSHAKE_TIMEOUT, remaining))
def recv(self, amount: int) -> bytes:
self._arm()
return self._sock.recv(amount)
def sendall(self, data: bytes) -> None:
self._arm()
self._sock.sendall(data)
def setsockopt(self, level: int, optname: int, value: int) -> None:
self._sock.setsockopt(level, optname, value)
def close(self) -> None:
self._sock.close()
def probe_ota_key(
remote_host: str | list[str],
remote_port: int,
noise_psk: str,
*,
timeout: float = PROBE_TIMEOUT,
retry_rejected: bool = True,
) -> ProbeResult:
"""Whether the device completes a Noise handshake with the key, no image
sent. Retries until the deadline, a rejection too with ``retry_rejected``
(the old firmware may answer once more after an upload)."""
noise_handshake(noise_psk, b"") # a local fault must not read as the device's no
deadline = time.monotonic() + timeout
def pause(delay: float) -> None:
time.sleep(max(0.0, min(delay, deadline - time.monotonic())))
res: list[Any] = []
answered: set[Any] = set() # addresses whose answer no retry changes
last_error = "no time left"
outcome = ProbeResult.UNREACHABLE # a device answer outranks a network error
while time.monotonic() < deadline:
if not res:
# A rebooting device may drop off mDNS for a while; keep asking.
# Addresses the caller pre-resolved (the address cache, or a
# literal IP) are its to keep; only a hostname is looked up again
try:
res = _resolve_targets(remote_host, remote_port)
except OTAError as err:
if outcome is ProbeResult.UNREACHABLE:
last_error = str(err)
_LOGGER.debug("Host not resolved yet (%s); retrying", err)
pause(PROBE_RETRY_DELAY)
continue
# One round: every address that has not answered, back to back
started = time.monotonic()
unreached = 0
for af, socktype, _, _, sa in res:
if sa in answered:
continue
if (remaining := deadline - time.monotonic()) <= 0:
break
sock = socket.socket(af, socktype)
# A dead host must not eat the budget
sock.settimeout(min(PROBE_CONNECT_TIMEOUT, remaining))
with contextlib.closing(sock):
try:
sock.connect(sa)
session = _negotiate_session(
_DeadlineSocket(sock, deadline), noise_psk
)[0]
# A password challenge also proves the key: it comes encrypted
receive_exactly(session, 1, "auth", list(_AUTH_ANSWERS))
except (OSError, OTANetworkError) as err:
if outcome is ProbeResult.UNREACHABLE:
last_error = f"{sa[0]}: {err}"
unreached += 1
except OTAError as err:
# Final in the precheck; a rebooting device may answer
# differently once it is back
found = next(
(r for t, r in _PROBE_ANSWERS if isinstance(err, t)),
ProbeResult.DEVICE_ERROR,
)
# A stale address answering with some other error must
# not hide what the device said about the key
if found is not ProbeResult.DEVICE_ERROR or not any(
outcome is r for _, r in _PROBE_ANSWERS
):
outcome, last_error = found, f"{sa[0]}: {err}"
if not retry_rejected:
answered.add(sa)
else:
_LOGGER.info("Device %s accepted the key", sa[0])
return ProbeResult.ACCEPTED
_LOGGER.debug("Key not accepted yet (%s); retrying", last_error)
# Every address gets its own final say; a stale one cannot answer
# for the device
pending = sum(1 for *_, sa in res if sa not in answered)
if pending == 0 or time.monotonic() >= deadline:
break
if unreached == pending:
res = [] # no answer this round: the device may have a new address
pause(PROBE_RETRY_DELAY - (time.monotonic() - started))
_LOGGER.warning("Checking the key failed, %s: %s", outcome.value, last_error)
return outcome
def run_ota_impl_(
remote_host: str | list[str],
remote_port: int,
@@ -900,16 +1098,13 @@ def run_ota_impl_(
plaintext_fallback: bool = False,
allow_plaintext_upload: bool = False,
old_noise_psk: str | None = None,
on_upload_start: Callable[[], None] | None = None,
) -> tuple[int, str | None]:
from esphome.core import CORE
# Handle both single host and list of hosts
try:
# Resolve all hosts at once for parallel DNS resolution
res = resolve_ip_address(
remote_host, remote_port, address_cache=CORE.address_cache
)
except EsphomeError as err:
res = _resolve_targets(remote_host, remote_port)
except OTAError:
_LOGGER.error(
"Error resolving IP address of %s. Is it connected to WiFi?",
remote_host,
@@ -920,11 +1115,7 @@ def run_ota_impl_(
"(If this error persists, please set a static IP address: "
"https://esphome.io/components/wifi/#manual-ips)"
)
raise OTAError(err) from err
if not res:
_LOGGER.error("No addresses to connect to for %s", remote_host)
return 1, None
raise
# Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries
# are shared across the addresses, cycling through them. Wait before an
@@ -941,6 +1132,13 @@ def run_ota_impl_(
reconnect = False # the device closed on purpose; nothing to wait for
attempt = 0
encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk)
def upload_started() -> None:
nonlocal on_upload_start
if on_upload_start is not None:
on_upload_start()
on_upload_start = None # once per upload, whatever the retries do
while attempt < total_attempts:
af, socktype, _, _, sa = res[attempt % len(res)]
if not reconnect and (reached_device or attempt >= len(res)):
@@ -978,6 +1176,7 @@ def run_ota_impl_(
encryption.noise_psk,
encryption.plaintext_fallback,
allow_plaintext_upload=allow_plaintext_upload,
on_upload_start=upload_started,
)
except OTAKeyRejected as err:
# The device sent a reject and closed on its own, so reconnect
@@ -1013,6 +1212,8 @@ def run_ota_impl_(
# Device-reported error (wrong password, wrong flash size, ...);
# retrying cannot succeed, so fail immediately
_LOGGER.error(str(err))
if isinstance(err, OTAEncryptionNotOffered):
_LOGGER.error(ENCRYPTION_NOT_OFFERED_ADVICE)
return 1, None
if encryption.tried_old_key:
@@ -1038,7 +1239,11 @@ def run_ota(
plaintext_fallback: bool = False,
allow_plaintext_upload: bool = False,
old_noise_psk: str | None = None,
on_upload_start: Callable[[], None] | None = None,
) -> tuple[int, str | None]:
"""``on_upload_start`` runs once, when the device first accepted the
session and the image is about to go out: the point from which it may
commit, which a later retry or failure does not undo."""
try:
return run_ota_impl_(
remote_host,
@@ -1050,6 +1255,7 @@ def run_ota(
plaintext_fallback,
allow_plaintext_upload,
old_noise_psk,
on_upload_start,
)
except OTAError as err:
_LOGGER.error(err)
+1 -1
View File
@@ -792,7 +792,7 @@ def lint_unit_constants_usage():
# Maximum allowed CONF_ constants in esphome/const.py.
# This file is frozen — new constants go in esphome/components/const/__init__.py.
# Decrease this number when constants are moved out of const.py.
CONST_PY_MAX_CONF = 1016
CONST_PY_MAX_CONF = 1025
@lint_content_check(include=["esphome/const.py"])
@@ -0,0 +1,71 @@
"""CodSpeed benchmarks for importing entity components.
Every ``esphome config`` or ``esphome compile`` imports the Python module of
each component the YAML uses. A component that imports its optional
integrations (mqtt, web_server, zigbee) at module scope drags their whole
import chains in for every user, so the cost of a fresh import is what these
benchmarks pin down.
The core of esphome is imported once up front and stays loaded; each run
drops only the component modules from ``sys.modules`` and imports the
component again, so the import machinery and the module bodies of the
component and everything it pulls in run every time, as they do in a new
process, while a change to the core moves no number here. The modules the
session had before a case are put back afterwards, so later tests keep the
objects they already hold.
Skipped when ``pytest-codspeed`` isn't installed so the regular unit-test
suite keeps working unchanged.
"""
from __future__ import annotations
from collections.abc import Generator
import importlib
import sys
import pytest
pytest.importorskip("pytest_codspeed")
# Load the core before the first case so no case pays for it.
import esphome.codegen # noqa: E402, F401
import esphome.config_validation # noqa: E402, F401
COMPONENTS = ("binary_sensor", "sensor", "logger")
COMPONENT_PACKAGE = "esphome.components"
COMPONENT_PREFIX = COMPONENT_PACKAGE + "."
def _component_modules() -> dict[str, object]:
# The package itself goes too: ``from esphome.components import x`` is
# answered from its attributes, so a stale package would hide the import.
return {
n: m
for n, m in sys.modules.items()
if n == COMPONENT_PACKAGE or n.startswith(COMPONENT_PREFIX)
}
def _forget_component_modules() -> None:
for name in _component_modules():
del sys.modules[name]
def _import_component(name: str) -> object:
_forget_component_modules()
return importlib.import_module(COMPONENT_PREFIX + name)
@pytest.fixture
def restore_component_modules() -> Generator[None]:
saved = _component_modules()
yield
_forget_component_modules()
sys.modules.update(saved)
@pytest.mark.usefixtures("restore_component_modules")
@pytest.mark.parametrize("component", COMPONENTS)
def test_import_component(benchmark, component: str) -> None:
benchmark(_import_component, component)
@@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin(
assert (
result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP"
)
def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None:
"""An empty wakeup_pin list is rejected at validation instead of crashing codegen."""
with pytest.raises(cv.Invalid, match="at least one entry"):
deep_sleep.validate_wakeup_pin([])
def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"):
deep_sleep.validate_config(config)
def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On ESP32 the same mode-only config is rejected too, instead of being silently dead."""
set_core_config(PlatformFramework.ESP32_IDF)
config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"):
deep_sleep.validate_config(config)
@pytest.mark.parametrize(
"platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR]
)
def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform(
set_core_config: SetCoreConfigCallable, platform: PlatformFramework
) -> None:
"""wakeup_pin is rejected where the C++ component has no wakeup pin support."""
set_core_config(platform)
with pytest.raises(cv.Invalid, match="only available on"):
deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"})
def test_deep_sleep_esp32_multiple_wakeup_pins_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""ESP32 accepts only one wakeup_pin entry."""
set_core_config(PlatformFramework.ESP32_IDF)
config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]}
with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc:
deep_sleep.validate_config(config)
assert exc.value.path == [CONF_WAKEUP_PIN]
def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {
CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}],
deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE",
}
with pytest.raises(cv.Invalid, match="define it per pin") as exc:
deep_sleep.validate_config(config)
assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE]
+90
View File
@@ -102,6 +102,16 @@ from esphome.core import CORE
"File can't be opened as image",
id="invalid_image_file",
),
pytest.param(
{
"id": "image_id",
"file": "image.png",
"type": "rgb111",
"transparency": "chroma_key",
},
"Image format 'RGB111' cannot have transparency: chroma_key",
id="rgb111_with_chroma_key_transparency",
),
],
)
def test_file_platform_configuration_errors(
@@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame(
assert all(a == 0x00 for a in frame1_alpha), (
f"Frame 1 alpha plane should be transparent, got {frame1_alpha}"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
(
"mode",
"transparency",
"frame0_color",
"frame1_color",
"frame0_row",
"frame1_row",
),
[
pytest.param(
"RGB",
CONF_OPAQUE,
(255, 0, 0),
(0, 0, 255),
[0x92, 0x48],
[0x24, 0x92],
id="rgb111",
),
pytest.param(
"RGBA",
CONF_ALPHA_CHANNEL,
(255, 0, 0, 255),
(0, 0, 255, 0),
[0x99, 0x99, 0x90],
[0x22, 0x22, 0x20],
id="rgb111_alpha",
),
],
)
async def test_rgb111_layout_per_frame(
tmp_path: Path,
mock_progmem_array: MagicMock,
mode: str,
transparency: str,
frame0_color: tuple[int, ...],
frame1_color: tuple[int, ...],
frame0_row: list[int],
frame1_row: list[int],
) -> None:
"""RGB111 packs one bit per channel MSB first and pads every row to a byte,
so each frame is a self-contained block of row_stride * height bytes.
"""
# A width that is not a multiple of 8 leaves padding bits in every row.
width = 5
height = 3
frame0 = PILImage.new(mode, (width, height), frame0_color)
frame1 = PILImage.new(mode, (width, height), frame1_color)
apng_path = tmp_path / "anim.png"
frame0.save(
apng_path,
format="PNG",
save_all=True,
append_images=[frame1],
duration=100,
loop=0,
)
config = {
CONF_FILE: str(apng_path),
CONF_TYPE: "RGB111",
CONF_TRANSPARENCY: transparency,
CONF_DITHER: "NONE",
CONF_INVERT_ALPHA: False,
CONF_RAW_DATA_ID: "test_raw_data_id",
}
_, _, _, _, _, frame_count = await write_image(config, all_frames=True)
assert frame_count == 2
_, raw_data = mock_progmem_array.call_args.args
data = [int(x) for x in raw_data]
frame_size = len(frame0_row) * height
assert data == frame0_row * height + frame1_row * height, (
f"Expected {frame_size} row-padded bytes per frame, got {data}"
)
+53 -1
View File
@@ -2,14 +2,23 @@
from __future__ import annotations
from contextlib import nullcontext
import pytest
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER
from esphome.components.display import add_metadata
from esphome.components.lvgl import final_validation
from esphome.components.lvgl.defines import get_lv_images_used
from esphome.config import Config
from esphome.config_validation import Invalid
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.const import (
CONF_ID,
CONF_TYPE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID
from esphome.final_validate import full_config
@@ -175,3 +184,46 @@ class TestDrawRoundingMerge:
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0]["draw_rounding"] == 2
class TestImageTypeValidation:
"""Test the image types LVGL accepts."""
@pytest.mark.parametrize(
("image_type", "expectation"),
[
pytest.param(
"RGB111", pytest.raises(Invalid, match="does not support RGB111")
),
pytest.param("RGB565", nullcontext()),
],
)
def test_image_type(self, image_type: str, expectation) -> None:
image_id = ID("my_image", True)
fc = full_config.get()
fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}]
fc.declare_ids.append((image_id, ["image", 0, CONF_ID]))
get_lv_images_used().add(image_id)
with expectation:
final_validation([_make_lvgl_config(["my_disp"])])
@pytest.mark.parametrize(
("image_type", "expectation"),
[
pytest.param(
"RGB111", pytest.raises(Invalid, match="does not support RGB111")
),
pytest.param("RGB565", nullcontext()),
],
)
def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None:
"""A sendspin artwork image declares its ID below the slot that holds the type."""
image_id = ID("album_art", True)
fc = full_config.get()
fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}]
fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID]))
get_lv_images_used().add(image_id)
with expectation:
final_validation([_make_lvgl_config(["my_disp"])])
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
core_options:
OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1"
OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5"
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
+166 -5
View File
@@ -1,11 +1,22 @@
"""Tests for the openthread component schema."""
"""Tests for the openthread component."""
from collections.abc import Callable
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6
from esphome.components.openthread import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.core import HexInt
from esphome.components.esp32 import (
KEY_ESP32,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32C6,
)
from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE
from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX
import esphome.config_validation as cv
from esphome.const import PlatformFramework, Toolchain
from esphome.core import CORE, HexInt
from esphome.yaml_util import Sensitive
from tests.component_tests.types import SetCoreConfigCallable
@@ -37,3 +48,153 @@ def test_credentials_are_sensitive(
assert isinstance(validated[field], Sensitive)
assert validated[field] == expected
assert isinstance(validated[field], HexInt) == isinstance(expected, int)
def _generate_in(
tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str]
) -> tuple[Path, MagicMock]:
"""Generate from ``yaml_text`` written to tmp_path, so the build directory lands there.
Returns the header path and the mocked ``clean_build``.
"""
config_path = tmp_path / "test.yaml"
config_path.write_text(yaml_text, encoding="utf-8")
with patch("esphome.components.openthread.clean_build") as clean_build:
generate_main(config_path)
return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build
def test_core_options_written_to_header(
tmp_path: Path,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
header, _ = _generate_in(
tmp_path,
component_config_path("custom_header.yaml").read_text(encoding="utf-8"),
generate_main,
)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True
assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR
assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE
assert header.read_text(encoding="utf-8") == (
"// Auto-generated by ESPHome from openthread core_options - do not edit\n"
"#pragma once\n\n"
"#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n"
"#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n"
)
def test_no_header_options_no_header(
tmp_path: Path,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
header, clean_build = _generate_in(
tmp_path,
component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"),
generate_main,
)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig
assert not header.exists()
clean_build.assert_not_called()
@pytest.mark.parametrize(
("toolchain", "cleans"),
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
)
def test_header_change_cleans_only_on_platformio(
toolchain: Toolchain,
cleans: bool,
tmp_path: Path,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = component_config_path("custom_header.yaml").read_text(encoding="utf-8")
CORE.toolchain = toolchain
_, clean_build = _generate_in(tmp_path, source, generate_main)
assert clean_build.called is cleans
# Unchanged content: no clean on either toolchain.
CORE.reset()
CORE.toolchain = toolchain
_, clean_build = _generate_in(tmp_path, source, generate_main)
clean_build.assert_not_called()
# A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread.
changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"')
assert changed != source
CORE.reset()
CORE.toolchain = toolchain
header, clean_build = _generate_in(tmp_path, changed, generate_main)
assert clean_build.called is cleans
assert (
"#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n"
in header.read_text(encoding="utf-8")
)
@pytest.mark.parametrize(
"name",
[
"CONFIG_OPENTHREAD_JOINER",
"openthread_config_x",
"OT_CONFIG_X",
OPENTHREAD_CONFIG_PREFIX,
],
)
def test_core_option_without_prefix_rejected(
name: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
)
with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX):
CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}})
@pytest.mark.parametrize(
"name",
["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"],
)
def test_core_option_bad_characters_rejected(
name: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
)
with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"):
CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}})
@pytest.mark.parametrize(
("value", "message"),
[
("", "must not be empty"),
(" ", "must not be empty"),
("1\n#define X 2", "must be a single line"),
("1\r2", "must be a single line"),
("1 \\", "must not end with"),
("1 \\ ", "must not end with"),
],
)
def test_core_option_bad_value_rejected(
value: str, message: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
)
with pytest.raises(cv.Invalid, match=message):
CONFIG_SCHEMA(
{
"network_key": hex(NETWORK_KEY),
"core_options": {"OPENTHREAD_CONFIG_X": value},
}
)
def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.NRF52_ZEPHYR)
with pytest.raises(cv.Invalid, match="ESP32"):
CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}})
@@ -0,0 +1,44 @@
"""Test playback timeout C++ generation."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"])
def test_playback_timeout_codegen(
timeout: str | None,
tmp_path: Path,
generate_main: Callable[[Path], str],
) -> None:
config = tmp_path / "voice_assistant.yaml"
config.write_text(
"""esphome:
name: test-voice-assistant
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: test-network
api:
i2s_audio:
i2s_lrclk_pin: GPIO25
i2s_bclk_pin: GPIO26
microphone:
- platform: i2s_audio
id: test_mic
adc_type: external
i2s_din_pin: GPIO34
voice_assistant:
microphone: test_mic
"""
+ (f" tts_playback_start_timeout: {timeout}\n" if timeout else "")
)
main = generate_main(config)
if timeout in (None, "2s", "2000ms"):
assert "set_tts_playback_start_timeout(" not in main
else:
milliseconds = 1 if timeout == "1ms" else 10000
assert f"set_tts_playback_start_timeout({milliseconds});" in main
+6
View File
@@ -24,3 +24,9 @@ sensor:
tag_name: P1
emontx_id: test_emontx
unit_of_measurement: W
text_sensor:
- platform: emontx
emontx_id: test_emontx
version:
name: emonTx firmware version
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The platform's own to_code needs a configured hub; only its define is wanted here.
async def to_code_testing(config: ConfigType) -> None:
cg.add_define("USE_EMONTX_VERSION")
manifest.to_code = to_code_testing
@@ -0,0 +1,116 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/emontx/emontx.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "../common.h"
namespace esphome::emontx::testing {
namespace {
class EmonTxVersionTest : public ::testing::Test {
protected:
void SetUp() override {
// The test main does not construct App as generated code does
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
this->sut_.set_uart_parent(&this->uart_);
this->sut_.set_version_text_sensor(&this->version_);
}
void TearDown() override {
App.scheduler.cancel_timeout(&this->sut_, uint32_t{0});
App.scheduler.call(millis());
}
// Lets the delayed 'v' go out
static void run_timers() {
App.scheduler.call(millis());
delay(300); // NOLINT - test only, the scheduler reads the real clock
App.scheduler.call(millis());
}
std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); }
MockUARTComponent uart_;
EmonTx sut_;
text_sensor::TextSensor version_;
};
} // namespace
// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command
TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) {
this->sut_.setup();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) {
this->sut_.set_lock_on_reboot(true);
this->sut_.setup();
EXPECT_EQ(this->tx_(), "\nemonunlock\n\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n");
}
TEST_F(EmonTxVersionTest, VersionLinePublished) {
this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n");
this->sut_.loop();
ASSERT_TRUE(this->version_.has_state());
EXPECT_EQ(this->version_.get_state(), "v1.1.1");
}
TEST_F(EmonTxVersionTest, OtherLinesNotPublished) {
this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n");
this->sut_.loop();
EXPECT_FALSE(this->version_.has_state());
}
// MSG restarts at 1 after a reboot, e.g. a firmware update
TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) {
this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n");
this->sut_.loop();
EXPECT_TRUE(this->uart_.tx.empty());
this->uart_.push_rx("{\"MSG\":1}\n");
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
// The emonTx may have been flashed during the pause, with MSG already past its old value
TEST_F(EmonTxVersionTest, ResumeRequestsVersion) {
this->sut_.set_paused(true);
this->sut_.loop();
this->sut_.set_paused(false);
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) {
this->sut_.setup();
this->sut_.set_paused(true);
this->sut_.loop();
run_timers();
EXPECT_EQ(this->tx_(), "\n");
this->sut_.set_paused(false);
this->sut_.loop();
run_timers();
EXPECT_EQ(this->tx_(), "\n\nv\n");
}
} // namespace esphome::emontx::testing
+4
View File
@@ -17,5 +17,9 @@ select:
es8388_id: es8388_parent
dac_output:
name: "DAC Output"
id: es8388_test_dac_output_select
initial_option: BOTH
adc_input_mic:
name: "ADC Input MIC"
id: es8388_test_adc_input_mic_select
initial_option: LINE2
@@ -0,0 +1,12 @@
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
for platform in ("sensor", "binary_sensor"):
CORE.testing_ensure_platform_registered(platform)
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"]
@@ -0,0 +1,62 @@
binary_sensor:
- platform: template
name: "Fan Running"
id: fan_running
sensor:
- platform: template
name: "Fan Speed Percent"
id: fan_speed_percent
unit_of_measurement: "%"
# Test with lambdas
- platform: filter_lifetime
name: "Filter Lifetime Lambda"
id: filter_lifetime_lambda
max_lifetime: 365d
is_on: !lambda 'return true;'
current_speed: !lambda 'return 50.0;'
update_interval: 60s
runtime_hours:
name: "Filter Runtime Hours Lambda"
remaining_days:
name: "Filter Remaining Days Lambda"
# Test with sensor references
- platform: filter_lifetime
name: "Filter Lifetime Sensor"
id: filter_lifetime_sensor
max_lifetime: 365d
is_on_sensor: fan_running
current_speed_sensor: fan_speed_percent
update_interval: 60s
runtime_hours:
name: "Filter Runtime Hours Sensor"
remaining_days:
name: "Filter Remaining Days Sensor"
# Test with defaults (always on at 100%)
- platform: filter_lifetime
name: "Filter Lifetime Default"
id: filter_lifetime_default
max_lifetime: 180d
update_interval: 60s
button:
- platform: template
name: "Reset Filter Lambda"
on_press:
- filter_lifetime.reset_filter:
id: filter_lifetime_lambda
- platform: template
name: "Reset Filter Sensor"
on_press:
- filter_lifetime.reset_filter:
id: filter_lifetime_sensor
- platform: template
name: "Reset Filter Default"
on_press:
- filter_lifetime.reset_filter:
id: filter_lifetime_default
@@ -0,0 +1,8 @@
sensor:
- platform: filter_lifetime
name: "Filter Lifetime Lambda Only"
max_lifetime: 180d
is_on: !lambda 'return true;'
current_speed: !lambda 'return 75.0f;'
remaining_days:
name: "Filter Remaining Days Lambda Only"
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include lambda_only.yaml
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include common.yaml
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include common.yaml
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include common.yaml
@@ -0,0 +1,224 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/filter_lifetime/filter_lifetime.h"
#include "esphome/components/host/preferences.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::filter_lifetime::testing {
class EntityIdentity : public EntityBase {
public:
static void configure(EntityBase &entity, const char *name, uint32_t hash) {
auto configure = &EntityIdentity::configure_entity_;
(entity.*configure)(name, hash, 0);
}
};
class TestableFilterLifetime : public FilterLifetime {
public:
using FilterLifetime::last_update_;
using FilterLifetime::runtime_seconds_;
};
static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static constexpr uint32_t MAX_MINUTES = 600; // 10 hours
static constexpr uint32_t FILTER_HASH = 0x104001;
class FilterLifetimeTest : public ::testing::Test {
protected:
TestableFilterLifetime filter_;
sensor::Sensor runtime_hours_;
sensor::Sensor remaining_days_;
float last_state_{NAN};
unsigned publications_{0};
void SetUp() override {
new (&App) Application;
App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21);
host::setup_preferences();
host::host_preferences->reset();
g_is_on = true;
g_speed = 100.0f;
this->attach(this->filter_);
this->filter_.add_on_state_callback([this](float value) {
this->last_state_ = value;
++this->publications_;
});
}
void TearDown() override {
host::host_preferences->reset();
App.~Application();
}
void attach(FilterLifetime &filter) {
EntityIdentity::configure(filter, "Filter", FILTER_HASH);
filter.set_max_lifetime(MAX_MINUTES);
filter.set_is_on_lambda([]() { return g_is_on; });
filter.set_current_speed_lambda([]() { return g_speed; });
filter.set_runtime_hours_sensor(&this->runtime_hours_);
filter.set_remaining_days_sensor(&this->remaining_days_);
}
// Run one update() as if elapsed_ms passed since the previous one
void elapse(uint32_t elapsed_ms) {
this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms;
this->filter_.update();
}
bool stored(uint32_t &value) { return this->filter_.make_entity_preference<uint32_t>().load(&value); }
static float expected_percent(uint32_t runtime_seconds) {
return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES;
}
};
TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) {
this->filter_.setup();
EXPECT_EQ(this->publications_, 1u);
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f));
}
TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) {
this->filter_.setup();
g_speed = 50.0f;
this->elapse(60000);
EXPECT_EQ(this->filter_.runtime_seconds_, 30u);
EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30));
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f);
EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f));
}
TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) {
this->filter_.setup();
g_speed = 25.0f;
for (int i = 0; i < 3; i++)
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) {
this->filter_.setup();
g_is_on = false;
this->elapse(60000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
EXPECT_EQ(this->publications_, 2u);
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
}
TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) {
this->filter_.setup();
g_speed = 250.0f;
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
g_speed = NAN;
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
g_speed = -5.0f;
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) {
sensor::Sensor speed;
this->filter_.set_current_speed_lambda(nullptr);
this->filter_.set_current_speed_sensor(&speed);
this->filter_.setup();
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
speed.state = 50.0f;
this->elapse(2000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) {
binary_sensor::BinarySensor running;
this->filter_.set_is_on_lambda(nullptr);
this->filter_.set_is_on_sensor(&running);
this->filter_.setup();
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
running.publish_state(true);
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
running.publish_state(false);
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) {
this->filter_.set_is_on_lambda(nullptr);
this->filter_.set_current_speed_lambda(nullptr);
this->filter_.setup();
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) {
this->filter_.setup();
uint32_t value = 0;
this->elapse(3599000);
EXPECT_FALSE(this->stored(value));
this->elapse(1000);
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 3600u);
this->elapse(1000);
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 3600u);
}
TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) {
this->filter_.setup();
this->elapse(1000);
this->filter_.on_shutdown();
uint32_t value = 0;
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 1u);
}
TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) {
this->filter_.setup();
this->elapse(1234000);
this->filter_.on_shutdown();
TestableFilterLifetime restored;
this->attach(restored);
restored.setup();
EXPECT_EQ(restored.runtime_seconds_, 1234u);
EXPECT_FLOAT_EQ(restored.state, expected_percent(1234));
}
TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) {
this->filter_.setup();
this->elapse(5000);
this->filter_.reset_filter();
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
uint32_t value = 1;
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 0u);
}
TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) {
this->filter_.set_max_lifetime(1);
this->filter_.setup();
this->elapse(120000);
EXPECT_FLOAT_EQ(this->last_state_, 0.0f);
EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f);
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f);
}
} // namespace esphome::filter_lifetime::testing
#endif // USE_HOST
+45
View File
@@ -0,0 +1,45 @@
climate:
- platform: haier_tundra
name: Haier AC A
id: my_haier_ac_a
transmitter_id: xmitr
- platform: haier_tundra
name: Haier AC B
id: my_haier_ac_b
transmitter_id: xmitr
unit: b
switch:
- platform: haier_tundra
haier_tundra_id: my_haier_ac_a
quiet_mode:
name: "Quiet Mode (Unit A)"
turbo_mode:
name: "Turbo Mode (Unit A)"
health_mode:
name: "Health Mode (Unit A)"
- platform: haier_tundra
haier_tundra_id: my_haier_ac_b
quiet_mode:
name: "Quiet Mode (Unit B)"
turbo_mode:
name: "Turbo Mode (Unit B)"
health_mode:
name: "Health Mode (Unit B)"
button:
- platform: haier_tundra
haier_tundra_id: my_haier_ac_a
light:
name: "Toggle Light (Unit A)"
self_clean:
name: "Start Self Clean (Unit A)"
- platform: haier_tundra
haier_tundra_id: my_haier_ac_b
light:
name: "Toggle Light (Unit B)"
self_clean:
name: "Start Self Clean (Unit B)"
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
haier_tundra: !include common.yaml
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
haier_tundra: !include common.yaml
+73
View File
@@ -0,0 +1,73 @@
sensor:
- platform: icm20689
type: temperature
name: "ICM20689 Temperature"
- platform: motion
motion_id: icm20689_motion
type: acceleration_x
name: "ICM20689 Accel X"
accuracy_decimals: 4
filters:
- sliding_window_moving_average:
window_size: 4
send_every: 1
- platform: motion
motion_id: icm20689_motion
type: acceleration_y
name: "ICM20689 Accel Y"
accuracy_decimals: 4
- platform: motion
motion_id: icm20689_motion
type: acceleration_z
name: "ICM20689 Accel Z"
accuracy_decimals: 4
# Gyroscope axes (unit: °/s)
- platform: motion
motion_id: icm20689_motion
type: gyroscope_x
name: "ICM20689 Gyro X"
- platform: motion
motion_id: icm20689_motion
type: gyroscope_y
name: "ICM20689 Gyro Y"
- platform: motion
motion_id: icm20689_motion
type: gyroscope_z
name: "ICM20689 Gyro Z"
- platform: motion
motion_id: icm20689_motion
type: angular_rate_x
name: "ICM20689 Angular Rate X"
- platform: motion
motion_id: icm20689_motion
type: angular_rate_y
name: "ICM20689 Angular Rate Y"
- platform: motion
motion_id: icm20689_motion
type: angular_rate_z
name: "ICM20689 Angular Rate Z"
- platform: motion
motion_id: icm20689_motion
type: pitch
name: "ICM20689 Pitch"
- platform: motion
motion_id: icm20689_motion
type: roll
name: "ICM20689 Roll"
motion:
- platform: icm20689
id: icm20689_motion
cs_pin: GPIO5
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
# Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS
gyroscope_range: 500DPS
axis_map:
x: y
y: x
z: -z
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
icm20689: !include common.yaml
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
icm20689: !include common.yaml
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
icm20689: !include common.yaml
+16
View File
@@ -10,6 +10,22 @@ image:
type: BINARY
transparency: chroma_key
- platform: file
id: rgb111_image
file: ../../pnglogo.png
type: RGB111
- platform: file
id: rgb111_alpha_image
file: ../../pnglogo.png
type: RGB111
transparency: alpha_channel
- platform: file
id: rgb111_dither_alpha_image
file: ../../pnglogo.png
type: RGB111
dither: FloydSteinberg
transparency: alpha_channel
- platform: file
id: rgba_image
file: ../../pnglogo.png
+11
View File
@@ -37,6 +37,17 @@ image:
format: PNG
type: RGB
transparency: chroma_key
- platform: online_image
id: online_rgb111_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
type: RGB111
format: png
- platform: online_image
id: online_rgb111_transparent_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
type: RGB111
transparency: alpha_channel
format: png
- platform: online_image
id: online_binary_bmp
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
@@ -9,3 +9,5 @@ openthread:
pskc: 0xc23a76e98f1a6483639b1ac1271e2e27
mesh_local_prefix: fd53:145f:ed22:ad81::/64
output_power: 1dBm
core_options:
OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5"
+32
View File
@@ -8,6 +8,16 @@ pcf8574:
address: 0x22
pcf8575: false
interrupt_pin: ${interrupt_pin}
reset: true
- id: pcf8574_preserved
i2c_id: i2c_bus
address: 0x23
reset: false
- id: pcf8575_preserved
i2c_id: i2c_bus
address: 0x24
pcf8575: true
reset: false
binary_sensor:
- platform: gpio
@@ -18,6 +28,18 @@ binary_sensor:
number: 1
mode: INPUT
inverted: true
- platform: gpio
id: pcf8574_preserved_input
pin:
pcf8574: pcf8574_preserved
number: 1
mode: INPUT
- platform: gpio
id: pcf8575_preserved_input
pin:
pcf8574: pcf8575_preserved
number: 15
mode: INPUT
output:
- platform: gpio
@@ -27,3 +49,13 @@ output:
number: 0
mode: OUTPUT
inverted: false
- platform: gpio
id: pcf8574_preserved_output
pin:
pcf8574: pcf8574_preserved
number: 0
- platform: gpio
id: pcf8575_preserved_output
pin:
pcf8574: pcf8575_preserved
number: 8
+189
View File
@@ -0,0 +1,189 @@
#include <gtest/gtest.h>
#include "esphome/components/pcf8574/pcf8574.h"
namespace esphome::pcf8574::testing {
// Model quasi-bidirectional readback separately from the output latch.
class PortBus : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer,
size_t read_count) override {
EXPECT_EQ(address, 0x21);
if (read_count != 0) {
EXPECT_EQ(read_count, this->width);
this->reads++;
if (this->fail_read)
return i2c::ERROR_TIMEOUT;
uint16_t levels = this->latch & ~this->external_low;
for (size_t i = 0; i < read_count; i++)
read_buffer[i] = levels >> (8 * i);
}
if (write_count != 0) {
EXPECT_EQ(write_count, this->width);
this->writes++;
this->latch = write_buffer[0];
if (write_count == 2)
this->latch |= uint16_t(write_buffer[1]) << 8;
}
return i2c::ERROR_OK;
}
size_t width{1};
uint16_t latch{0};
uint16_t external_low{0};
unsigned reads{0};
bool fail_read{false};
unsigned writes{0};
};
class PCF8574ResetTest : public ::testing::TestWithParam<bool> {
protected:
void SetUp() override {
this->bus_.width = GetParam() ? 2 : 1;
this->mask_ = GetParam() ? 0xFFFF : 0xFF;
this->high_pin_ = GetParam() ? 15 : 7;
this->bus_.latch = 0xA55A & this->mask_;
this->component_.set_i2c_bus(&this->bus_);
this->component_.set_i2c_address(0x21);
this->component_.set_pcf8575(GetParam());
}
void configure_input_(uint8_t number) {
PCF8574GPIOPin pin;
pin.set_parent(&this->component_);
pin.set_pin(number);
pin.set_flags(gpio::FLAG_INPUT);
EXPECT_EQ(this->bus_.writes, 0);
EXPECT_EQ(this->bus_.reads, 0);
}
PortBus bus_;
PCF8574Component component_;
uint16_t mask_;
uint8_t high_pin_;
};
TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) {
this->configure_input_(1);
this->component_.setup();
ASSERT_EQ(this->bus_.writes, 1);
EXPECT_EQ(this->bus_.latch, this->mask_);
EXPECT_EQ(this->bus_.reads, 2);
this->component_.pin_mode(0, gpio::FLAG_OUTPUT);
EXPECT_EQ(this->bus_.writes, 1);
this->component_.digital_write(0, false);
EXPECT_EQ(this->bus_.latch, this->mask_ & ~1);
}
TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) {
this->component_.set_reset(false);
const uint16_t initial = this->bus_.latch;
this->component_.setup();
EXPECT_EQ(this->bus_.writes, 0);
EXPECT_EQ(this->bus_.reads, 1);
EXPECT_EQ(this->bus_.latch, initial);
// Consumers can write before calling their GPIO pin's setup().
this->component_.digital_write(0, true);
EXPECT_EQ(this->bus_.latch, initial | 1);
this->component_.pin_mode(0, gpio::FLAG_OUTPUT);
this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT);
const uint16_t high_pin_mask = 1 << this->high_pin_;
this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0);
const uint16_t expected = (initial | 1) ^ high_pin_mask;
EXPECT_EQ(this->bus_.latch, expected);
this->component_.digital_write(0, false);
EXPECT_EQ(this->bus_.latch, expected & ~1);
ASSERT_EQ(this->bus_.writes, 3);
}
TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) {
const uint16_t inputs = (1 << 1) | (1 << this->high_pin_);
this->bus_.latch |= inputs;
const uint16_t initial = this->bus_.latch;
this->bus_.external_low = inputs;
this->component_.set_reset(false);
this->configure_input_(1);
this->configure_input_(this->high_pin_);
this->component_.setup();
EXPECT_EQ(this->bus_.writes, 0);
this->component_.digital_write(0, true);
EXPECT_EQ(this->bus_.latch, initial | 1);
this->component_.pin_mode(1, gpio::FLAG_INPUT);
this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT);
EXPECT_EQ(this->bus_.latch, initial | 1);
EXPECT_FALSE(this->component_.digital_read(1));
}
TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) {
this->component_.set_reset(false);
this->configure_input_(1);
const uint16_t initial = this->bus_.latch;
this->component_.setup();
this->component_.pin_mode(1, gpio::FLAG_INPUT);
EXPECT_EQ(this->bus_.latch, initial);
this->component_.pin_mode(0, gpio::FLAG_OUTPUT);
this->component_.digital_write(0, true);
EXPECT_EQ(this->bus_.latch, initial | 1);
}
TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) {
const uint16_t loaded_pin = 1 << this->high_pin_;
this->bus_.latch |= loaded_pin;
this->bus_.external_low = loaded_pin;
const uint16_t initial = this->bus_.latch;
this->component_.set_reset(false);
this->component_.setup();
this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT);
EXPECT_EQ(this->bus_.writes, 0);
EXPECT_EQ(this->bus_.latch, initial);
this->component_.digital_write(0, true);
EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin);
}
TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) {
const uint16_t undeclared_pin = 1 << this->high_pin_;
this->bus_.latch |= undeclared_pin;
this->bus_.external_low = undeclared_pin;
const uint16_t initial = this->bus_.latch;
this->component_.set_reset(false);
this->component_.setup();
EXPECT_EQ(this->bus_.writes, 0);
EXPECT_EQ(this->bus_.latch, initial);
this->component_.digital_write(0, true);
this->bus_.external_low = 0;
EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin);
EXPECT_FALSE(this->component_.digital_read(this->high_pin_));
}
TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) {
this->bus_.fail_read = true;
this->component_.set_reset(false);
this->component_.setup();
EXPECT_TRUE(this->component_.is_failed());
this->component_.pin_mode(1, gpio::FLAG_INPUT);
this->component_.digital_write(0, true);
EXPECT_EQ(this->bus_.writes, 0);
}
INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool());
} // namespace esphome::pcf8574::testing
+17
View File
@@ -23,6 +23,10 @@ esphome:
then:
- logger.log: my_script is running
globals:
- id: nested_total
type: int
script:
- id: my_script
mode: single
@@ -71,3 +75,16 @@ script:
count: 5
then:
- logger.log: looping!
- repeat:
count: 2
then:
- repeat:
count: 2
then:
- delay: 200ms
- lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);'
- globals.set:
id: nested_total
value: !lambda |-
int total = iteration;
return total;
@@ -2,6 +2,7 @@
#include <memory>
#include <string>
#include <vector>
#include "esphome/components/script/script.h"
@@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) {
EXPECT_EQ(entity.value, 1);
}
class StopWaitAction : public Action<const std::string &> {
public:
void play(const std::string &value) override {
this->wait->stop_complex();
this->seen = value;
}
Action<const std::string &> *wait{nullptr};
std::string seen;
};
// The chain after the wait may stop it, e.g. a script with mode: restart
TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) {
FakeScript script;
ScriptWaitAction<FakeScript, const std::string &> wait(&script);
StopWaitAction stop;
stop.wait = &wait;
ActionList<const std::string &> actions;
actions.add_actions({&wait, &stop});
// With a second item waiting, stop() clears the queue while the first item's chain runs
actions.play(std::string("argument longer than the small string buffer"));
actions.play(std::string("second argument, also longer than the buffer"));
script.running = false;
wait.loop();
EXPECT_EQ(stop.seen, "argument longer than the small string buffer");
wait.loop();
EXPECT_EQ(stop.seen, "argument longer than the small string buffer");
}
class RestartAction : public Action<const std::string &> {
public:
void play(const std::string &value) override {
this->seen.push_back(value);
if (this->seen.size() > 1)
return;
// What a script with mode: restart does: stop, then run again while the awaited script is busy
this->wait->stop_complex();
this->script->running = true;
this->actions->play(std::string("restarted, longer than the small string buffer"));
}
Action<const std::string &> *wait{nullptr};
FakeScript *script{nullptr};
ActionList<const std::string &> *actions{nullptr};
std::vector<std::string> seen;
};
// The old pop_front() after the chain removed the item queued by the restart
TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) {
FakeScript script;
ScriptWaitAction<FakeScript, const std::string &> wait(&script);
RestartAction restart;
ActionList<const std::string &> actions;
actions.add_actions({&wait, &restart});
restart.wait = &wait;
restart.script = &script;
restart.actions = &actions;
// With a second item waiting, the stop really clears the queue; the restart then queues a new item
actions.play(std::string("first argument, longer than the small string buffer"));
actions.play(std::string("second argument, dropped by the stop"));
script.running = false;
wait.loop();
ASSERT_EQ(restart.seen.size(), 1u);
script.running = false;
wait.loop();
ASSERT_EQ(restart.seen.size(), 2u);
EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer");
}
TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) {
FakeScript script;
ScriptWaitAction<FakeScript, const std::string &> wait(&script);
RecordAction record;
ActionList<const std::string &> actions;
actions.add_actions({&wait, &record});
actions.play(std::string("argument"));
script.running = false;
wait.loop();
EXPECT_EQ(record.seen, "argument");
wait.loop();
EXPECT_TRUE(wait.is_idle());
}
} // namespace esphome::script::testing

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