Compare commits

...
Author SHA1 Message Date
J. Nick Koston 6d7fd54052 Merge remote-tracking branch 'origin/dev' into rtttl-tables-progmem 2026-10-13 10:57:30 -10:00
Bonne Eggleston 8721976066 [epaper_spi] Abandon an update when the busy pin never clears (#20555) 2026-10-14 07:47:09 +11:00
Bonne Eggleston 05bf7d3440 [epaper_spi] Write pixel blocks straight into mono buffers (#20552) 2026-10-14 07:40:56 +11:00
J. Nick Koston 9746e87a85 [rtttl] Keep the note frequency table in flash 2026-10-13 09:56:22 -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
119 changed files with 5285 additions and 257 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))
+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]))
@@ -1,4 +1,6 @@
#pragma once
#include <array>
#include <cstdint>
#include "esphome/core/color.h"
namespace esphome::display {
@@ -154,4 +156,54 @@ class ColorUtil {
return color;
}
};
/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */
static constexpr uint16_t REC601_LUMA_R = 77;
static constexpr uint16_t REC601_LUMA_G = 150;
static constexpr uint16_t REC601_LUMA_B = 29;
/** Rec.601 luma of an RGB color, for optimum perceptual brightness */
constexpr uint8_t rec601_luma(Color color) {
return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8);
}
/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division
* per channel.
*/
class Rgb565ToMono {
public:
explicit Rgb565ToMono(ColorOrder order) {
// Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order
uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B;
if (order == COLOR_ORDER_BGR) {
high = REC601_LUMA_B;
low = REC601_LUMA_R;
} else if (order == COLOR_ORDER_GRB) {
high = REC601_LUMA_G;
middle = REC601_LUMA_R;
}
// Each field value scaled to 0..255, then weighted
for (unsigned i = 0; i != 32; i++) {
this->high_[i] = high * esp_scale(i, 31);
this->low_[i] = low * esp_scale(i, 31);
}
for (unsigned i = 0; i != 64; i++)
this->middle_[i] = middle * esp_scale(i, 63);
}
uint8_t operator()(uint16_t value) const {
const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F];
return ((weighted + 128u) >> 8) >= 128 ? 1 : 0;
}
protected:
// Weighted contribution of each value of each field
std::array<uint16_t, 32> high_{};
std::array<uint16_t, 64> middle_{};
std::array<uint16_t, 32> low_{};
};
} // namespace esphome::display
+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")
+7 -12
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include <algorithm>
#include "esphome/components/display/display_color_utils.h"
#include "esphome/core/color.h"
/* Utility for converting internal \a Color RGB representation to supported IC hardware color keys
@@ -13,21 +14,15 @@
namespace esphome::epaper_spi {
// The Rec.601 luma helpers and the RGB565 mono lookup live in the shared display utilities, so other
// drivers (e.g. it8951 in its 1 bit per pixel mode) can use them too.
using display::color_to_mono;
using display::rec601_luma;
using display::Rgb565ToMono;
/** Delta for when to regard as gray */
static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */
constexpr uint8_t rec601_luma(Color color) {
return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8);
}
/** Map RGB color to a single monochrome bit
*
* @param color RGB color to convert from
* @return 1 = white, 0 = black
*/
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel)
*
* @param color RGB color to convert from
+133 -2
View File
@@ -131,9 +131,7 @@ void EPaperBase::update() {
}
void EPaperBase::wait_for_idle_(bool should_wait) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
this->waiting_for_idle_start_ = millis();
#endif
this->waiting_for_idle_ = should_wait;
}
@@ -156,6 +154,10 @@ void EPaperBase::loop() {
ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_));
#endif
} else {
if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) {
this->abandon_update_();
return;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
if (now - this->waiting_for_idle_last_print_ >= 1000) {
ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_());
@@ -168,6 +170,20 @@ void EPaperBase::loop() {
this->process_state_();
}
// The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full
void EPaperBase::abandon_update_() {
ESP_LOGE(TAG, "Display stayed busy for %u s in state %s; check the busy pin", (unsigned) this->busy_timeout_seconds_,
this->epaper_state_to_string_());
this->status_set_warning(LOG_STR("Busy pin timeout"));
if (this->state_ == EPaperState::TRANSFER_DATA)
this->abort_transfer();
// Leave the panel powered down rather than with its high-voltage drivers enabled
this->power_off();
this->full_update_requested_ = true;
this->current_data_index_ = 0;
this->set_state_(EPaperState::IDLE);
}
/**
* Process the state machine.
* Typical state sequence:
@@ -242,6 +258,7 @@ void EPaperBase::process_state_() {
break;
case EPaperState::DEEP_SLEEP:
this->deep_sleep();
this->status_clear_warning();
this->set_state_(EPaperState::IDLE);
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
break;
@@ -345,6 +362,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) {
}
}
namespace {
// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies
// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step
// rows.
template<bool SwapAxes, bool BigEndianSource>
void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count,
const Rgb565ToMono &to_mono) {
const ptrdiff_t byte_step = SwapAxes ? step * static_cast<ptrdiff_t>(row_width) : 0;
for (; count != 0; count--, source += 2) {
const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8);
if (to_mono(value)) {
*byte |= mask;
} else {
*byte &= ~mask;
}
if constexpr (SwapAxes) {
byte += byte_step;
} else if (step > 0) {
mask >>= 1;
if (mask == 0) {
mask = 0x80;
byte++;
}
} else {
mask <<= 1;
if (mask == 0) {
mask = 0x01;
byte--;
}
}
}
}
} // namespace
// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so
// the visible part of a block and its dirty area are worked out once for the whole block.
void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr,
ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) {
const bool swap = this->effective_transform_ & SWAP_XY;
const bool mirror_x = this->effective_transform_ & MIRROR_X;
const bool mirror_y = this->effective_transform_ & MIRROR_Y;
// The buffer axis a source column moves along, and the one a source row moves along
const int column_limit = swap ? this->height_ : this->width_;
const int row_limit = swap ? this->width_ : this->height_;
const bool column_mirrored = swap ? mirror_y : mirror_x;
const bool row_mirrored = swap ? mirror_x : mirror_y;
const int first_column = std::max(0, -x_start);
const int end_column = std::min(w, column_limit - x_start);
const int first_row = std::max(0, -y_start);
const int end_row = std::min(h, row_limit - y_start);
if (first_column >= end_column || first_row >= end_row)
return;
auto column_to_buffer = [&](int column) {
const int u = x_start + column;
return column_mirrored ? column_limit - 1 - u : u;
};
auto row_to_buffer = [&](int row) {
const int v = y_start + row;
return row_mirrored ? row_limit - 1 - v : v;
};
const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1));
const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1));
const int columns = end_column - first_column;
const int rows = end_row - first_row;
const int x_low = swap ? row_low : column_low;
const int y_low = swap ? column_low : row_low;
const int x_high = x_low + (swap ? rows : columns);
const int y_high = y_low + (swap ? columns : rows);
const Rgb565ToMono to_mono(order);
const size_t line_stride = x_offset + w + x_pad;
const size_t row_width = this->row_width_;
const int step = column_mirrored ? -1 : 1;
for (int row = first_row; row != end_row; row++) {
const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2;
const int along = column_to_buffer(first_column);
const int across = row_to_buffer(row);
const int x = swap ? across : along;
const int y = swap ? along : across;
uint8_t *byte = buffer + y * row_width + x / 8;
const uint8_t mask = 0x80u >> (x % 8);
if (swap) {
if (big_endian) {
write_rgb565_row<true, true>(byte, mask, step, row_width, source, columns, to_mono);
} else {
write_rgb565_row<true, false>(byte, mask, step, row_width, source, columns, to_mono);
}
} else if (big_endian) {
write_rgb565_row<false, true>(byte, mask, step, row_width, source, columns, to_mono);
} else {
write_rgb565_row<false, false>(byte, mask, step, row_width, source, columns, to_mono);
}
}
this->x_low_ = clamp_at_most(this->x_low_, x_low);
this->x_high_ = clamp_at_least(this->x_high_, x_high);
this->y_low_ = clamp_at_most(this->y_low_, y_low);
this->y_high_ = clamp_at_least(this->y_high_, y_high);
}
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at().
void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
size_t contiguous_length;
uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length);
if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 ||
contiguous_length != this->buffer_length_) {
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad);
}
void EPaperBase::dump_config() {
LOG_DISPLAY("", "E-Paper SPI", this);
ESP_LOGCONFIG(TAG,
+13 -2
View File
@@ -107,6 +107,8 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
void draw_pixel_at(int x, int y, Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
// Make the next update a full one. Applied when that update starts, so one in progress is not affected.
void request_full_update() { this->full_update_requested_ = true; }
// True from the start of an update until the display has refreshed and gone back to sleep.
@@ -125,9 +127,14 @@ class EPaperBase : public Display,
virtual bool initialise(bool partial);
void send_init_sequence_(const uint8_t *sequence, size_t length);
void wait_for_idle_(bool should_wait);
void abandon_update_();
bool init_buffer_(size_t buffer_length);
void update_effective_transform_();
bool rotate_coordinates_(int &x, int &y);
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
bool big_endian, int x_offset, int y_offset, int x_pad);
// Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly
virtual bool buffer_is_mono() const { return false; }
/**
* Methods that must be implemented by concrete classes to control the display
@@ -137,6 +144,8 @@ class EPaperBase : public Display,
* @return true if done, false if it should be called next loop
*/
virtual bool transfer_data() = 0;
// Called when an update is abandoned in the middle of transfer_data(), to release anything it left open
virtual void abort_transfer() {}
/**
* Refresh the screen after data transfer
*/
@@ -187,18 +196,20 @@ class EPaperBase : public Display,
// point to the pixel past the last one updated, i.e. may range up to width/height.
uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{};
uint32_t waiting_for_idle_start_{};
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
uint32_t waiting_for_idle_last_print_{};
uint32_t waiting_for_idle_start_{};
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
uint32_t update_start_time_{};
#endif
// properties with specific initialisers go last
EPaperState state_{EPaperState::IDLE};
uint32_t reset_duration_{10};
// Longer than any panel takes to refresh; past it the busy pin is assumed stuck
uint16_t busy_timeout_seconds_{120};
uint8_t full_update_every_{1};
EPaperState state_{EPaperState::IDLE};
};
} // namespace esphome::epaper_spi
@@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase {
}
protected:
bool buffer_is_mono() const override { return true; }
void refresh_screen(bool partial) override;
void power_on() override {}
void power_off() override{};
@@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() {
// A full update ignores 0x26, and the copy may not hold a real frame yet (first update after
// boot): send the new frame to both planes.
const bool send_copy = this->plane_ == 0 && this->update_count_ != 0;
const bool direct = send_copy || this->buffer_is_plane();
// A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper
// buffer (the four-level model) returns false and packs the plane in plane_row().
const bool direct = send_copy || this->buffer_is_mono();
const auto &source = send_copy ? this->sent_ : this->buffer_;
const size_t row_length = this->plane_row_length_();
const size_t plane_length = row_length * this->height_;
@@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono {
void init_comparison_frame_();
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
// Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is.
// A subclass with a deeper buffer returns false and overrides plane_row().
virtual bool buffer_is_plane() const { return true; }
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
virtual void plane_row(size_t y, uint8_t *out);
bool reset() override;
@@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 {
void draw_pixel_at(int x, int y, Color color) override;
protected:
bool buffer_is_mono() const override { return false; }
// A partial update if partial updates are enabled and this is not a full one.
bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); }
bool buffer_is_plane() const override { return false; }
void plane_row(size_t y, uint8_t *out) override;
void refresh_screen(bool partial) override;
bool transfer_data() override;
@@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() {
return true;
}
void EPaperT133A01::abort_transfer() {
// A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows
if (this->current_data_index_ == 0)
return;
this->disable();
this->cs_pin_->digital_write(true);
this->cs1_pin_->digital_write(true);
}
void EPaperT133A01::dump_config() {
EPaperBase::dump_config();
LOG_PIN(" CS Pin: ", this->cs_pin_);
@@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase {
void deep_sleep() override;
bool transfer_data() override;
void abort_transfer() override;
/**
* Send a command (and optional data) selecting one or both controllers.
@@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase {
}
protected:
bool buffer_is_mono() const override { return true; }
bool initialise(bool partial) override;
bool transfer_data() override;
void refresh_screen(bool partial) override;
+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;
+4 -6
View File
@@ -891,12 +891,10 @@ static uint8_t color_to_nibble(const Color &color) {
if (color.raw_32 == 0xFFFFFFFF)
return 0x0F; // white
// Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by
// 256). Rec.601 is the standard for converting SDR images to grayscale and
// spreads saturated colours across the mid-range; Rec.709 instead crams them
// against white/black where the 16 panel levels are hard to tell apart.
auto luma = static_cast<uint8_t>((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8);
return quantize_8bit_to_nibble(luma);
// Rec.601 luma is the standard for converting SDR images to grayscale and spreads saturated colours
// across the mid-range; Rec.709 instead crams them against white/black where the 16 panel levels are
// hard to tell apart.
return quantize_8bit_to_nibble(display::rec601_luma(color));
}
// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535).
+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_; }
+5 -5
View File
@@ -17,10 +17,10 @@ static constexpr uint8_t MAX_OCTAVE = 7;
static constexpr uint8_t DEFAULT_BPM = 63; // Default beats per minute
// These values can also be found as constants in the Tone library (Tone.h)
static constexpr uint16_t NOTES[] = {0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494,
523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, 1047,
1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217,
2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951};
static constexpr uint16_t NOTES[] PROGMEM = {
0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, 523, 554, 587, 622,
659, 698, 740, 784, 831, 880, 932, 988, 1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661,
1760, 1865, 1976, 2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951};
static constexpr uint8_t NOTES_COUNT = static_cast<uint8_t>(sizeof(NOTES) / sizeof(NOTES[0]));
static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10;
@@ -211,7 +211,7 @@ void Rtttl::loop() {
this->finish_();
return;
}
uint16_t freq = NOTES[note_index];
uint16_t freq = progmem_read_uint16(&NOTES[note_index]);
need_note_gap = freq == this->output_freq_;
// Add small silence gap between same note
@@ -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();
+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_{};
+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
+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)
@@ -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
@@ -0,0 +1,145 @@
#include <gtest/gtest.h>
#include <algorithm>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h"
#include "esphome/components/epaper_spi/epaper_spi_t133a01.h"
namespace esphome::epaper_spi::testing {
/// A busy pin the test can hold at "busy" and release.
class SwitchableBusyPin : public RecordingPin {
public:
bool digital_read() override { return this->busy; }
bool busy{false};
};
template<typename T> void loop_until_idle(T &display) {
const uint32_t deadline = millis() + 1000;
while (display.state() != EPaperState::IDLE && millis() < deadline)
display.loop();
}
class TestableSSD1683 : public EPaperSSD1683 {
public:
TestableSSD1683() : EPaperSSD1683("test", 16, 2, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
this->set_busy_pin(&this->busy);
this->set_full_update_every(5);
this->set_auto_clear(false);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait
}
void draw_whole_panel() {
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
EPaperState state() const { return this->state_; }
uint8_t update_count() const { return this->update_count_; }
void set_update_count(uint8_t count) { this->update_count_ = count; }
RecordingPin dc;
SwitchableBusyPin busy;
};
/// If the busy pin never clears, the update is abandoned instead of waiting for ever: the display goes back to
/// idle with a warning. Once the pin works again the next update is accepted and is a full one, even though nothing
/// was drawn since, and completing it clears the warning.
TEST(EPaperBusyTimeout, StuckBusyPinAbandonsTheUpdateAndRecovers) {
TestableSSD1683 display;
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.set_update_count(3);
// Stick the pin after the data has been sent, when the dirty area has already been emptied
display.draw_whole_panel();
display.update();
const uint32_t deadline = millis() + 1000;
while (display.state() <= EPaperState::TRANSFER_DATA && millis() < deadline)
display.loop();
ASSERT_EQ(display.state(), EPaperState::POWER_ON);
display.busy.busy = true;
loop_until_idle(display);
EXPECT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned";
EXPECT_TRUE(display.status_has_warning());
display.busy.busy = false;
display.update();
EXPECT_NE(display.state(), EPaperState::IDLE) << "a later update was refused";
loop_until_idle(display);
EXPECT_EQ(display.state(), EPaperState::IDLE) << "the later update did not complete";
EXPECT_EQ(display.update_count(), 1) << "the later update was not a full one";
EXPECT_FALSE(display.status_has_warning());
}
class TestableT133A01 : public EPaperT133A01 {
public:
TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
this->set_cs_pins(&this->cs, &this->cs1);
this->set_busy_pin(&this->busy);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait
}
void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); }
EPaperState state() const { return this->state_; }
RecordingPin dc, cs, cs1;
SwitchableBusyPin busy;
};
/// The T133A01 waits for the busy pin with a data stream open. Abandoning the update there must close the SPI
/// transaction and release both chip selects, or the next command would block.
TEST(EPaperBusyTimeout, AbandonedT133A01TransferReleasesTheBus) {
TestableT133A01 display;
TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5);
display.install(&delegate);
display.start_transfer();
display.loop();
ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA);
ASSERT_FALSE(display.cs.level) << "the transfer should have yielded with its data stream open";
display.busy.busy = true;
loop_until_idle(display);
ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned";
EXPECT_EQ(delegate.begin_count, delegate.end_count) << "SPI transaction left open";
EXPECT_TRUE(display.cs.level) << "CS left asserted";
EXPECT_TRUE(display.cs1.level) << "CS1 left asserted";
}
/// Abandoning an update powers the panel off, so it is not left with its high-voltage drivers enabled.
TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) {
TestableT133A01 display;
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 5); // burn time so the transfer yields mid-stream
display.install(&bus);
display.start_transfer();
display.loop();
ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA);
display.busy.busy = true;
loop_until_idle(display);
ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned";
EXPECT_NE(std::find(bus.commands.begin(), bus.commands.end(), 0x02), bus.commands.end())
<< "the panel was not powered off (POF) when the update was abandoned";
}
} // namespace esphome::epaper_spi::testing
@@ -0,0 +1,193 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/colorconv.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h"
namespace esphome::epaper_spi::testing {
/// Exposes the buffer and the dirty bounds so the fast path can be compared with drawing pixel by pixel.
template<typename Driver> class TestableDisplay : public Driver {
public:
TestableDisplay(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {
EXPECT_TRUE(this->init_buffer_(this->buffer_length_));
this->reset_bounds();
}
void reset_bounds() {
this->x_low_ = this->width_;
this->y_low_ = this->height_;
this->x_high_ = 0;
this->y_high_ = 0;
}
std::vector<uint8_t> buffer() const {
std::vector<uint8_t> out;
for (size_t i = 0; i != this->buffer_length_; i++)
out.push_back(this->buffer_[i]);
return out;
}
/// Reallocates the buffer in blocks of at most max_block bytes, as SplitBuffer does when memory is fragmented.
void split_buffer(size_t max_block) {
ASSERT_TRUE(this->buffer_.init(this->buffer_length_, max_block));
ASSERT_GT(this->buffer_.get_buffer_count(), 1u);
this->clear();
this->reset_bounds();
}
std::vector<uint16_t> bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; }
void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
// The per-pixel path the fast one must match
display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call)
x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
}
};
/// RGB565 data with a spread of light and dark pixels, including values either side of the threshold.
static std::vector<uint8_t> rgb565(size_t pixels, uint32_t seed) {
std::vector<uint8_t> out;
uint32_t state = seed;
for (size_t i = 0; i != pixels; i++) {
state = state * 1103515245u + 12345u;
const uint16_t pixel = static_cast<uint16_t>(state >> 8);
out.push_back(pixel & 0xFF);
out.push_back(pixel >> 8);
}
return out;
}
/// Random bytes for a block of pixels, enough for 3 bytes a pixel.
static std::vector<uint8_t> random_bytes(size_t pixels, uint32_t seed) {
std::vector<uint8_t> out;
uint32_t state = seed;
for (size_t i = 0; i != pixels * 3; i++) {
state = state * 1103515245u + 12345u;
out.push_back(static_cast<uint8_t>(state >> 16));
}
return out;
}
/// For every rotation, mirroring and byte order, with source offsets and padding, and with blocks inside the panel,
/// running off each edge and larger than the panel, drawing an RGB565 block directly gives the same buffer and the same
/// dirty area as drawing it pixel by pixel. The panel width is not a multiple of 8.
TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) {
struct Block {
int x, y, w, h;
};
const Block blocks[] = {{6, 5, 12, 6}, {-3, -2, 10, 8}, {14, 8, 12, 9}, {-5, 4, 30, 3}, {-2, -3, 26, 20}};
const display::DisplayRotation rotations[] = {
display::DISPLAY_ROTATION_0_DEGREES, display::DISPLAY_ROTATION_90_DEGREES, display::DISPLAY_ROTATION_180_DEGREES,
display::DISPLAY_ROTATION_270_DEGREES};
// Swapping the axes comes from the 90 and 270 degree rotations
const uint8_t mirrorings[] = {NONE, MIRROR_X, MIRROR_Y, MIRROR_X | MIRROR_Y};
uint32_t seed = 1;
for (const auto rotation : rotations) {
for (const uint8_t mirroring : mirrorings) {
for (const bool big_endian : {false, true}) {
for (const auto &block : blocks) {
TestableDisplay<EPaperSSD1683> fast(21, 13);
TestableDisplay<EPaperSSD1683> slow(21, 13);
for (auto *display : {&fast, &slow}) {
display->set_transform(mirroring);
display->set_rotation(rotation);
}
// a 3 pixel left offset, 2 rows skipped and 4 pixels of padding
const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++);
fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB,
display::COLOR_BITNESS_565, big_endian, 3, 2, 4);
slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB,
display::COLOR_BITNESS_565, big_endian, 3, 2, 4);
const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring)
<< " big endian " << big_endian << " block " << block.x << ","
<< block.y << " " << block.w << "x" << block.h;
EXPECT_EQ(fast.buffer(), slow.buffer()) << where;
EXPECT_EQ(fast.bounds(), slow.bounds()) << where;
}
}
}
}
}
/// The colour order reaches the RGB565 block writer.
TEST(EPaperDrawPixels, ColourOrderIsHonoured) {
for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) {
TestableDisplay<EPaperSSD1683> fast(21, 13);
TestableDisplay<EPaperSSD1683> slow(21, 13);
const auto data = random_bytes(21 * 13, 99);
fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order);
}
}
/// With a clipping region set the block goes through draw_pixel_at(), which honours it.
TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) {
TestableDisplay<EPaperSSD1683> fast(24, 16);
TestableDisplay<EPaperSSD1683> slow(24, 16);
const auto data = rgb565(24 * 16, 7);
for (auto *display : {&fast, &slow})
display->start_clipping(4, 4, 12, 10);
fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer());
EXPECT_EQ(fast.bounds(), (std::vector<uint16_t>{4, 4, 12, 10}));
}
/// A driver whose buffer is not 1 bit per pixel keeps its own draw_pixel_at() for blocks too.
TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) {
TestableDisplay<EPaperSSD1677Gray4> fast(16, 4);
TestableDisplay<EPaperSSD1677Gray4> slow(16, 4);
const auto data = rgb565(16 * 4, 11);
fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer());
}
/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order.
TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) {
for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) {
const Rgb565ToMono to_mono(order);
size_t mismatches = 0;
for (uint32_t value = 0; value != 0x10000; value++) {
const uint8_t expected = color_to_mono(ColorUtil::to_color(value, order, display::COLOR_BITNESS_565));
if (to_mono(static_cast<uint16_t>(value)) != expected)
mismatches++;
}
EXPECT_EQ(mismatches, 0u) << "order " << int(order);
}
}
/// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result.
TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) {
{
TestableDisplay<EPaperSSD1683> fast(24, 16);
TestableDisplay<EPaperSSD1683> slow(24, 16);
fast.split_buffer(7);
slow.split_buffer(7);
const auto data = rgb565(24 * 16, 5);
fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0,
0);
slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0,
0);
EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer";
EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer";
}
for (const auto bitness : {display::COLOR_BITNESS_888, display::COLOR_BITNESS_332}) {
TestableDisplay<EPaperSSD1683> fast(24, 16);
TestableDisplay<EPaperSSD1683> slow(24, 16);
const auto data = random_bytes(24 * 16, 9);
fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness);
EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness);
}
}
} // namespace esphome::epaper_spi::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"

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