Compare commits

..
Author SHA1 Message Date
J. Nick Koston e01b244c5f Merge remote-tracking branch 'origin/dev' into epaper-spi-fill-dirty-bounds
# Conflicts:
#	esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h
2026-10-13 11:41:24 -10:00
J. Nick Koston 0dc1d3b5a1 [toshiba] Move the lookup tables to PROGMEM (#20636) 2026-10-13 11:25:18 -10:00
J. Nick Koston 017e379c87 Use mark_all_dirty_() for the full canvas bounds and fix the 4bpp class comment 2026-10-13 11:16:28 -10:00
tomaszduda23andClaude 42341d05f9 [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 11:12:08 -10:00
J. Nick Koston f941fee5ed [homeassistant] Add sensor platform benchmarks (#20637) 2026-10-13 11:10:14 -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 476b442cd0 Fill the four level SSD1677 buffer through fill_buffer() like the other drivers 2026-10-13 10:01:14 -10:00
J. Nick Koston 49ab7d058a Merge remote-tracking branch 'origin/dev' into epaper-spi-fill-dirty-bounds
# Conflicts:
#	esphome/components/epaper_spi/epaper_spi.h
2026-10-13 09:58:20 -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 3f7ec21717 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-17 08:48:00 -05:00
J. Nick Koston b89f20cec5 Merge remote-tracking branch 'upstream/dev' into HEAD 2026-09-16 09:30:19 -05:00
J. Nick Koston fa04972a26 [epaper_spi] Tighten fill helper comments 2026-09-13 18:50:39 -05:00
J. Nick Koston 99b5d4a35a [epaper_spi] Mark the canvas dirty from the base fill() so no driver can skip it 2026-09-13 18:24:23 -05:00
125 changed files with 4068 additions and 3336 deletions
+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
-38
View File
@@ -2232,12 +2232,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
return 0
def command_rotate_key(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.cli.rotate_key import command_rotate_key as run
return run(args, config)
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.cli.rename import command_rename as run
@@ -2264,7 +2258,6 @@ POST_CONFIG_ACTIONS = {
"clean-mqtt": command_clean_mqtt,
"idedata": command_idedata,
"rename": command_rename,
"rotate-key": command_rotate_key,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
"bundle": command_bundle,
@@ -2662,37 +2655,6 @@ def parse_args(argv):
)
parser_rename.add_argument("name", help="The new name for the device.", type=str)
parser_rotate_key = subparsers.add_parser(
"rotate-key",
help=(
"Change the OTA encryption key over the air: write a new key to the "
"yaml, build with it, upload with the current key, confirm."
),
parents=[mqtt_options],
)
parser_rotate_key.add_argument(
"configuration", help="Your YAML configuration file.", nargs=1
)
parser_rotate_key.add_argument(
"--device",
action="append",
help=ARGUMENT_HELP_DEVICE,
)
parser_rotate_key.add_argument(
"--prompt-new-key",
action="store_true",
help=(
"Ask for the new key instead of generating one; read without echo, "
"or as one line from stdin when that is not a terminal."
),
)
parser_rotate_key.add_argument(
"-y",
"--yes",
action="store_true",
help="Do not ask for confirmation when the api encryption key changes too.",
)
parser_analyze_memory = subparsers.add_parser(
"analyze-memory",
help="Analyze memory usage by component.",
+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))
-285
View File
@@ -1,285 +0,0 @@
"""``esphome rotate-key``."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from esphome import espota2
from esphome.components.noise import (
generate_encryption_key,
static_encryption_key,
validate_encryption_key,
)
from esphome.config_validation import Invalid
from esphome.const import CONF_API, CONF_PASSWORD, CONF_PORT
from esphome.core import CORE, EsphomeError
from esphome.espota2 import ProbeResult
from esphome.log import AnsiFore, color
from esphome.ota_key_edit import locate_key_edits, old_key_edit, with_sharers
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
ESPHOME_COMMAND,
read_secret_line,
run_external_process,
safe_input,
safe_print,
)
from esphome.yaml_edit import RestoreError, Snapshot, apply_line_edits, restore_files
PRECHECK_TIMEOUT = 15.0
# What to do when the device does not accept the key in the yaml before anything is written
PRECHECK_ADVICE = {
ProbeResult.UNREACHABLE: (
"The device could not be reached; check that it is online, then try again."
),
ProbeResult.REJECTED: (
"The device runs a different key than the yaml. Install the current "
"configuration first, presenting its key with --prompt-ota-key, then rotate."
),
ProbeResult.NOT_OFFERED: (
"The device does not offer encryption: it runs a firmware older than "
"ESPHome 2026.9.0 or without a key. Install the current configuration "
"first, then rotate."
),
ProbeResult.DEVICE_ERROR: (
"The device answered with an error (see the warning above); fix that first."
),
}
def command_rotate_key(args: argparse.Namespace, config: ConfigType) -> int | None:
"""Write a new key, build with it, upload with the current one, confirm."""
from esphome.__main__ import (
Purpose,
_esphome_ota_conf,
_resolve_network_devices,
choose_upload_log_host,
)
def fail(message: str) -> int:
safe_print(color(AnsiFore.BOLD_RED, message))
return 1
ota_conf = _esphome_ota_conf(config)
if ota_conf is None:
return fail("rotate-key needs the esphome OTA platform in the configuration")
old_key = static_encryption_key(ota_conf)
if old_key is None:
return fail(
"rotate-key needs 'encryption:' under the esphome OTA platform with a "
"key in the yaml; a key provisioned at runtime is rotated from Home "
"Assistant"
)
if CORE.is_host:
return fail("rotate-key is for devices updated over the air")
old_key = str(old_key)
remote_port = int(ota_conf[CONF_PORT])
devices = choose_upload_log_host(
default=args.device, check_default=None, purpose=Purpose.UPLOADING
)
if get_port_type(devices[0]) in (PortType.SERIAL, PortType.BOOTSEL):
return fail(
"rotate-key works over the air; on a serial connection change the "
"key in the yaml and run 'esphome run' instead"
)
network_devices = _resolve_network_devices(devices, config, args)
if args.prompt_new_key:
new_key = read_secret_line("New OTA encryption key: ")
else:
new_key = generate_encryption_key()
try:
validate_encryption_key(new_key)
except Invalid as err:
return fail(f"Invalid new key: {err}")
if new_key == old_key:
return fail("The new key is the same as the current one")
try:
edits = locate_key_edits(old_key, new_key)
# The previous key stays in the config as old_key so an install still
# reaches the device if it ends up running either key
edits += old_key_edit(old_key)
unchecked = with_sharers(edits)
except EsphomeError as err:
return fail(str(err))
warnings = []
# With the recommended shape the ota inherits the api key, so rotating
# it changes what Home Assistant connects with
if str(static_encryption_key(config.get(CONF_API) or {})) == old_key:
warnings.append(
"The api encryption key changes too; Home Assistant will ask for "
"the new key after the install."
)
shared: dict[str, set[str]] = {}
for edit in edits:
what = f"secret '{edit.secret}'" if edit.secret else edit.path.name
for path in edit.shared_with:
shared.setdefault(
str(path.relative_to(CORE.config_dir.resolve())), set()
).add(what)
if unchecked:
warnings.append(
"Could not check every configuration for shared use: "
+ "; ".join(unchecked)
)
if shared:
warnings.append(
"Other configurations share what is rewritten: "
+ "; ".join(
f"{p} ({', '.join(sorted(w))})" for p, w in sorted(shared.items())
)
+ ". Their devices keep the previous key until their next install; "
"make sure those configurations carry it as old_key."
)
if warnings:
for warning in warnings:
safe_print(color(AnsiFore.BOLD_YELLOW, warning))
if not args.yes:
if not sys.stdin.isatty():
return fail("Confirm with --yes when there is no terminal")
if safe_input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
return 1
safe_print("Checking that the device accepts the current key...")
found = espota2.probe_ota_key(
network_devices,
remote_port,
old_key,
timeout=PRECHECK_TIMEOUT,
retry_rejected=False,
)
if found is not ProbeResult.ACCEPTED:
return fail(PRECHECK_ADVICE[found])
# Both keys are shown only when a file could not be put back
keys = f"Previous key: {old_key}\nNew key: {new_key}"
try:
originals = apply_line_edits(edits)
except RestoreError as err:
safe_print(keys)
return fail(str(err))
except EsphomeError as err:
return fail(str(err))
except KeyboardInterrupt as err:
if notes := getattr(err, "__notes__", None): # a rollback that failed too
safe_print(keys)
return fail("Interrupted; " + "; ".join(notes))
return fail("Interrupted before the upload")
# From here every exit restores or reports, so nothing sits outside the try
uploaded = False
try:
safe_print(
"Wrote the new key to "
+ ", ".join(color(AnsiFore.CYAN, str(p)) for p in originals)
)
# Global options go before the subcommand; the compile parser is strict
cli_args = ["--dashboard"] if CORE.dashboard else []
if args.toolchain:
cli_args += ["--toolchain", str(args.toolchain)]
for key, value in args.substitution or []:
cli_args += ["-s", key, value]
cli_args += ["compile", str(CORE.config_path)]
if run_external_process(*ESPHOME_COMMAND, *cli_args) != 0:
return fail("Compiling with the new key failed")
def image_sent() -> None:
# The device accepted the session and may commit from here on;
# the edited config reaches it on either key
nonlocal uploaded
uploaded = True
rc, _ = espota2.run_ota(
network_devices,
remote_port,
ota_conf.get(CONF_PASSWORD),
CORE.firmware_bin,
noise_psk=old_key,
plaintext_fallback=False,
on_upload_start=image_sent,
)
if rc != 0:
return fail(
"Uploading with the current key failed. The configuration keeps "
"the new key with the previous one as old_key, so the next "
"install reaches the device whichever key it runs."
if uploaded
else "The upload did not reach the device."
)
safe_print("Waiting for the device to come back with the new key...")
found = espota2.probe_ota_key(network_devices, remote_port, new_key)
if found is not ProbeResult.ACCEPTED:
return fail(
(
"The device still answers with the previous key."
if found is ProbeResult.REJECTED
else "The device did not come back to confirm the new key."
)
+ " The configuration keeps the new key, with the previous one as "
"old_key, so the next install reaches the device either way."
)
except KeyboardInterrupt:
return fail(
"Interrupted; the device's key was not confirmed"
if uploaded
else "Interrupted before the upload"
)
finally:
# Before the upload nothing changed on the device; after it the device
# most likely runs the new key, and old_key covers the other case
if uploaded:
safe_print(f"New OTA encryption key: {color(AnsiFore.CYAN, new_key)}")
else:
_restore(originals, keys)
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
return 0
def _restore(originals: dict[Path, Snapshot], keys: str) -> None:
"""Put the previous key back, and drop the images a build made with the
new key; both keys are shown when a step fails."""
files = ", ".join(str(p) for p in originals)
try:
restore_files(originals)
except (EsphomeError, KeyboardInterrupt) as err:
# An interrupted restore carries its report as a note
why = str(err) or "; ".join(getattr(err, "__notes__", ()))
safe_print(color(AnsiFore.BOLD_RED, why or f"Interrupted restoring {files}"))
safe_print(keys)
else:
safe_print(color(AnsiFore.BOLD_YELLOW, f"Restored the previous key in {files}"))
build_dir = CORE.firmware_bin.parent
failed: list[str] = []
images: list[Path] = []
try:
images = [p for s in (".bin", ".uf2") for p in build_dir.glob(f"*{s}")]
for image in images: # every one is tried, whatever the others did
try:
image.unlink()
except OSError as err:
failed.append(f"{image}: {err}")
except (OSError, KeyboardInterrupt) as err:
failed.append(f"{build_dir}: {str(err) or 'interrupted'}")
if failed:
safe_print(
color(
AnsiFore.BOLD_RED,
"Could not remove images that may hold the new key: "
+ "; ".join(failed),
)
)
safe_print(keys)
elif images:
safe_print(
color(
AnsiFore.BOLD_YELLOW,
f"Removed the images in {build_dir}, which may hold the new key; "
"compile again before the next upload.",
)
)
+1 -27
View File
@@ -33,35 +33,9 @@ _CACHE_VERSION = 1
_LAMBDA_KEY = "__esphome_lambda__"
_CACHE_SUFFIX = ".validated.json"
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}{_CACHE_SUFFIX}"
def invalidate_compiled_config() -> None:
"""Drop every cache in the storage dir after a change to a file the
mtime check cannot see, such as secrets.yaml or an include; other
configurations may share that file."""
storage = compiled_config_path(CORE.config_filename).parent
try:
caches = [p for p in storage.iterdir() if p.name.endswith(_CACHE_SUFFIX)]
except FileNotFoundError:
return
except OSError as err:
raise EsphomeError(
f"Could not list the validated config caches in {storage}: {err}"
) from err
for path in caches:
try:
path.unlink(missing_ok=True)
except OSError as err:
# A stale cache would present the old key on the next upload
raise EsphomeError(
f"Could not remove the validated config cache {path}: {err}"
) from err
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
def save_compiled_config(config: ConfigType) -> None:
@@ -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
+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
+134 -6
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:
@@ -200,10 +216,7 @@ void EPaperBase::process_state_() {
// Refresh the whole panel even if nothing was drawn
this->full_update_requested_ = false;
this->update_count_ = 0;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->mark_all_dirty_();
}
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
this->set_state_(EPaperState::IDLE);
@@ -242,6 +255,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 +359,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,
+30 -14
View File
@@ -81,22 +81,15 @@ class EPaperBase : public Display,
DisplayType get_display_type() override { return this->display_type_; };
// Default implementation for monochrome displays
void fill(Color color) override {
// If clipping is active, fall back to base implementation
// Whole buffer fills bypass draw_pixel_at(), so the dirty bounds are set here once for every driver.
void fill(Color color) final {
// If clipping is active, fall back to the per-pixel base implementation
if (this->get_clipping().is_set()) {
Display::fill(color);
return;
}
auto pixel_color = color_to_mono(color) ? 0xFF : 0x00;
// We store 8 pixels per byte
this->buffer_.fill(pixel_color);
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->x_low_ = 0;
this->y_low_ = 0;
this->fill_buffer(color);
this->mark_all_dirty_();
}
void clear() override {
@@ -107,6 +100,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.
@@ -116,6 +111,18 @@ class EPaperBase : public Display,
int get_height_internal() override { return this->height_; };
int get_width_internal() override { return this->width_; };
bool is_using_partial_update_() const { return this->full_update_every_ > 1; }
/// Fill the whole frame buffer with the color.
virtual void fill_buffer(Color color) {
// We store 8 pixels per byte
this->buffer_.fill(color_to_mono(color) ? 0xFF : 0x00);
}
/// An update with no dirty area is skipped, so whole buffer writes must call this.
void mark_all_dirty_() {
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void process_state_();
const char *epaper_state_to_string_();
@@ -125,9 +132,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 +149,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 +201,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
@@ -6,28 +6,11 @@ namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.4bpp");
void EPaper4bpp::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
void EPaper4bpp::fill_buffer(Color color) {
auto pixel_color = this->color_to_native(color);
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
// Whole buffer just changed; mark the entire canvas dirty.
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaper4bpp::clear() {
// clear buffer to white, just like real paper.
this->fill(COLOR_ON);
}
void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) {
@@ -7,7 +7,7 @@ namespace esphome::epaper_spi {
/**
* Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte).
*
* Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by
* Owns buffer sizing, fill_buffer()/draw_pixel_at() and the chunked SPI transfer loop shared by
* this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via
* color_to_native() plus their IC-specific power/refresh/sleep command sequences.
*/
@@ -19,10 +19,8 @@ class EPaper4bpp : public EPaperBase {
this->buffer_length_ = width * height / 2; // 2 pixels per byte
}
void fill(Color color) override;
void clear() override;
protected:
void fill_buffer(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
@@ -39,12 +39,7 @@ void EPaperInkplate2::deep_sleep() {
this->cmd_data(0x07, {0xA5});
}
void EPaperInkplate2::fill(Color color) {
if (this->get_clipping().is_set()) {
EPaperBase::fill(color); // clipping active: defer to the base per-pixel path
return;
}
void EPaperInkplate2::fill_buffer(Color color) {
const size_t half_buffer = this->buffer_length_ / 2;
// Plane encoding: B/W plane 1=white, 0=black; red plane 0=red, 1=no-red.
@@ -70,22 +65,15 @@ void EPaperInkplate2::fill(Color color) {
this->buffer_[i] = bw_byte;
for (size_t i = half_buffer; i < this->buffer_length_; i++)
this->buffer_[i] = red_byte;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaperInkplate2::clear() { this->fill(COLOR_ON); }
void HOT EPaperInkplate2::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
const size_t half_buffer = this->buffer_length_ / 2;
const size_t pos = y * this->row_width_ + x / 8;
const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill() for plane encoding
const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill_buffer() for plane encoding
switch (to_inkplate2_color(color)) {
case Inkplate2Color::BLACK:
@@ -14,11 +14,10 @@ class EPaperInkplate2 final : public EPaperBase {
this->buffer_length_ = this->row_width_ * this->height_ * 2;
}
void fill(Color color) override;
void clear() override;
void draw_pixel_at(int x, int y, Color color) override;
protected:
void fill_buffer(Color color) override;
void refresh_screen(bool partial) override;
void power_on() override;
void power_off() override;
@@ -19,13 +19,7 @@ static JD79660Color HOT color_to_hex(Color color) {
return color_to_bwyr(color, JD79660Color::BLACK, JD79660Color::WHITE, JD79660Color::YELLOW, JD79660Color::RED);
}
void EPaperJD79660::fill(Color color) {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
void EPaperJD79660::fill_buffer(Color color) {
const auto pixel_color = color_to_hex(color);
// We store 4 pixels per byte
@@ -34,9 +34,8 @@ class EPaperJD79660 final : public EPaperBase {
this->buffer_length_ = this->row_width_ * height;
}
void fill(Color color) override;
protected:
void fill_buffer(Color color) override;
/** Draw colored pixel into frame buffer */
void draw_pixel_at(int x, int y, Color color) override;
@@ -33,10 +33,7 @@ void EPaperMono::set_window() {
// if not using partial update, the display will go into deep sleep, so must rewrite entire
// buffer since the display RAM will not retain contents
if (!this->is_using_partial_update_()) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
this->mark_all_dirty_();
}
// round x-coordinates to byte boundaries
this->x_low_ &= ~7;
@@ -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{};
@@ -47,10 +47,7 @@ bool HOT EPaperSSD1677::transfer_data() {
const auto start_time = millis();
if (this->current_data_index_ == 0) {
if (this->plane_ == 0) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
this->mark_all_dirty_();
}
this->set_window();
this->command(this->plane_ == 0 ? 0x26 : 0x24);
@@ -58,7 +55,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;
@@ -21,18 +21,9 @@ static uint8_t plane_byte(uint8_t first, uint8_t second, bool high_bit) {
return out;
}
void EPaperSSD1677Gray4::fill(Color color) {
if (this->get_clipping().is_set()) {
// Falls back to the generic per-pixel implementation for the clipped rectangle.
EPaperBase::fill(color);
return;
}
void EPaperSSD1677Gray4::fill_buffer(Color color) {
const uint8_t level = color_to_gray4(color);
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
@@ -64,10 +55,7 @@ bool HOT EPaperSSD1677Gray4::transfer_data() {
if (first_pass) {
// With partial updates enabled the window follows the changed area, but the four-level
// refresh drives every pixel from both planes, and the reset before it does not keep RAM.
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
this->mark_all_dirty_();
this->set_window();
}
this->command(first_pass ? 0x24 : 0x26);
@@ -34,13 +34,13 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 {
this->buffer_length_ = (size_t) this->row_width_ * height;
}
void fill(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
protected:
void fill_buffer(Color color) override;
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;
@@ -35,10 +35,7 @@ void EPaperSSD1683::set_window() {
// if not using partial update, the display will go into deep sleep mode 2, so must rewrite entire
// buffer since the display RAM will not retain contents
if (!this->is_using_partial_update_()) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
this->mark_all_dirty_();
}
// round x-coordinates to byte boundaries
@@ -225,11 +225,7 @@ void EPaperT133A01::write_command_(uint8_t command, const uint8_t *data, size_t
this->cs1_pin_->digital_write(true);
}
void EPaperT133A01::fill(Color color) {
if (this->get_clipping().is_set()) {
EPaperBase::fill(color);
return;
}
void EPaperT133A01::fill_buffer(Color color) {
auto pixel_color = color_to_index(color);
this->buffer_.fill(pixel_color + (pixel_color << 4));
}
@@ -364,6 +360,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_);
@@ -31,13 +31,12 @@ class EPaperT133A01 : public EPaperBase {
this->cs1_pin_ = cs1;
}
void fill(Color color) override;
void setup() override;
void dump_config() override;
void draw_pixel_at(int x, int y, Color color) override;
protected:
void fill_buffer(Color color) override;
bool reset() override;
bool initialise(bool partial) override;
void refresh_screen(bool partial) override;
@@ -46,6 +45,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;
@@ -49,7 +49,7 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
}
}
void EPaperWaveshareBWR::fill(Color color) {
void EPaperWaveshareBWR::fill_buffer(Color color) {
const size_t half_buffer = this->buffer_length_ / 2u;
const auto bwr = color_to_bwr(color);
@@ -26,9 +26,8 @@ class EPaperWaveshareBWR : public EPaperBase {
this->buffer_length_ = this->row_width_ * height * 2;
}
void fill(Color color) override;
protected:
void fill_buffer(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
bool transfer_data() override;
void refresh_screen(bool partial) override;
@@ -60,7 +60,7 @@ void EPaperWeAct3C::draw_pixel_at(int x, int y, Color color) {
}
}
void EPaperWeAct3C::fill(Color color) {
void EPaperWeAct3C::fill_buffer(Color color) {
// For 3-color e-paper with 1-bit buffer format:
// - Black buffer: 1=black, 0=white
// - Red buffer: 1=red, 0=no red
@@ -89,11 +89,6 @@ void EPaperWeAct3C::fill(Color color) {
}
}
void EPaperWeAct3C::clear() {
// Clear buffer to white, just like real paper.
this->fill(COLOR_ON);
}
void EPaperWeAct3C::set_window_() {
// For full screen refresh, we always start from (0,0)
// The y_low_/y_high_ values track the dirty region for optimization,
@@ -22,10 +22,8 @@ class EPaperWeAct3C : public EPaperBase {
this->buffer_length_ = this->row_width_ * height * 2;
}
void fill(Color color) override;
void clear() override;
protected:
void fill_buffer(Color color) override;
void set_window_();
void refresh_screen(bool partial) override;
void power_on() 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))
@@ -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))
+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).
+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);
}
}
+4 -2
View File
@@ -1040,11 +1040,13 @@ def run_compile(args, config: ConfigType) -> bool:
framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
bootloader = zephyr_data()[KEY_BOOTLOADER]
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes.
# The bootloader resets on a package whose sd_req does not list its SoftDevice,
# so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840).
_GENPKG_PARAMS = {
BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"),
}
# UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant
_UF2_FAMILY_IDS = {
+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_; }
+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_;
};
+22 -28
View File
@@ -3,8 +3,6 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <vector>
namespace esphome::toshiba {
struct RacPt1411hwruFanSpeed {
@@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72;
const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E;
// RAC-PT1411HWRU Swing
const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9;
const std::vector<uint8_t> RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB};
const std::vector<uint8_t> RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA};
static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB};
static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA};
// RAC-PT1411HWRU Fan speeds
const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B;
constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66};
@@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E;
// Hence these look-up tables. In addition, the upper nibble is used here for additional
// "negative" and "fractional value" flags as required for some temperatures.
// RAC-PT1411HWRU °C Temperatures (short codes)
const std::vector<uint8_t> RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05,
0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B};
static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05,
0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B};
// RAC-PT1411HWRU °F Temperatures (short codes)
const std::vector<uint8_t> RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02,
0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C,
0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B};
static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = {
0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05,
0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B};
// RAS-2819T protocol constants
const uint16_t RAS_2819T_HEADER1 = 0xC23D;
@@ -213,7 +211,7 @@ static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09;
static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame
// RAS-2819T temperature codes for 18-30°C
static const uint8_t RAS_2819T_TEMP_CODES[] = {
static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = {
0x10, // 18°C
0x30, // 19°C
0x20, // 20°C
@@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) {
return 0x40; // Default to 24°C
}
return RAS_2819T_TEMP_CODES[temp_index];
return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]);
}
/**
@@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) {
// Find the code in the temperature array
for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) {
if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) {
if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) {
return static_cast<float>(temp_index + 18); // 18°C is the minimum
}
}
@@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() {
index = static_cast<uint8_t>(roundf(temp_adjd));
if (this->model_ == MODEL_RAC_PT1411HWRU_F) {
code = RAC_PT1411HWRU_TEMPERATURE_F[index];
code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]);
message[9] |= RAC_PT1411HWRU_FLAG_FAH;
} else {
code = RAC_PT1411HWRU_TEMPERATURE_C[index];
code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]);
}
if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) {
code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY;
@@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() {
// Swing Mode
data->reset();
data->space(TOSHIBA_PACKET_SPACE);
switch (this->swing_mode) {
case climate::CLIMATE_SWING_VERTICAL:
this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1);
break;
case climate::CLIMATE_SWING_OFF:
default:
this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1);
}
const uint8_t *swing_frame =
this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF;
uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH];
progmem_memcpy(swing, swing_frame, sizeof(swing));
this->encode_(data, swing, sizeof(swing), 1);
data->space(TOSHIBA_PACKET_SPACE);
transmit.perform();
@@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
temperature_code =
(message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG);
if (message[15] & RAC_PT1411HWRU_FLAG_FAH) {
for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) {
if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) {
for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) {
if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) {
this->target_temperature = static_cast<float>((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9;
}
}
} else {
for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) {
if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) {
for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) {
if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) {
this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN;
}
}
@@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
break;
// Swing mode
case RAC_PT1411HWRU_SWING_HEADER:
if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) {
if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
+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_{};
+1
View File
@@ -72,6 +72,7 @@
#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
-376
View File
@@ -1,376 +0,0 @@
"""Rewrite the OTA encryption key in place: the `key:` line the loader read
it from, or the secrets.yaml line a `!secret` on it points to."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import os
from pathlib import Path
import re
from esphome.components.noise import static_encryption_key
from esphome.const import CONF_API, CONF_ENCRYPTION, CONF_KEY
from esphome.core import CORE, EsphomeError
from esphome.espota2 import CONF_OLD_KEY, esphome_ota_items
from esphome.types import ConfigType
from esphome.yaml_edit import (
LineEdit,
editable_file,
field_line_re,
own_documents,
read_text,
rewrite,
source_line,
source_of,
)
from esphome.yaml_util import secrets_path_for
_LOGGER = logging.getLogger(__name__)
_DOMAIN = "ota_key_edit"
@dataclass
class KeyEdit(LineEdit):
"""A line edit with the secret it holds and the other configs using it."""
secret: str | None = None
shared_with: list[Path] = field(default_factory=list)
def _api_encryption() -> ConfigType:
return ((CORE.raw_config or {}).get(CONF_API) or {}).get(CONF_ENCRYPTION) or {}
def _key_blocks(raw: ConfigType, key: str) -> list[ConfigType]:
"""The api and esphome ota ``encryption:`` mappings whose key is ``key``."""
blocks = [raw.get(CONF_API) or {}, *esphome_ota_items(raw)]
found = []
for block in blocks:
if (node := static_encryption_key(block)) is None or str(node) != key:
continue
encryption = block[CONF_ENCRYPTION]
if source_of(encryption, CONF_KEY) is not None:
found.append(encryption)
elif hasattr(next(k for k in encryption if k == CONF_KEY), "esp_range"):
# Read from a file but not on a line of its own, as with a merge
# key; only a key validation added, which has no range at all,
# is passed over (a bare ota block takes the api key)
raise EsphomeError(
f"The '{CONF_KEY}' of an '{CONF_ENCRYPTION}:' block is not on a "
"line of its own; edit the key by hand"
)
return found
_INDENT_RE = re.compile(r"\s*")
_KEY_FIELD_RE = re.compile(rf"^\s*{CONF_KEY}\s*:")
_BARE_BLOCK_RE = re.compile(rf"^\s*{CONF_ENCRYPTION}\s*:\s*(#.*)?$")
_YAML_SUFFIXES = (".yaml", ".yml")
_COMMENT_RE = re.compile(r"(?:^|\s)#.*$")
_INCLUDE_DIR_RE = re.compile(r"!include_dir_\w+\s+(?P<dir>[^\s#]+)")
# An include or package file whose path holds a substitution
_SUBSTITUTED_INCLUDE_RE = re.compile(
r"(?:!include\w*\s+|\bfile:\s*)[\"']?(?P<target>[^\s#\"']*\$[^\s#\"']*)"
)
def _secret_ref(name: str = r"[^\s\"']+") -> str:
"""`!secret name` as the whole scalar: `!secret ota key` is not `ota`, and
a comment needs whitespace before its `#`, so `a#b` is one name."""
return rf"!secret\s+(?P<q>[\"']?)(?P<name>{name})(?P=q)(?=\s+#|\s*$)"
def _secret_field_re(name: str) -> re.Pattern[str]:
"""``name: !secret <any>``."""
return re.compile(rf"^\s*{name}\s*:\s*{_secret_ref()}")
def _secret_use_re(name: str) -> re.Pattern[str]:
"""A use of the secret ``name`` anywhere on a line."""
return re.compile(_secret_ref(re.escape(name)))
_KEY_SECRET_RE = _secret_field_re(CONF_KEY)
_OLD_KEY_SECRET_RE = _secret_field_re(CONF_OLD_KEY)
def _indent(text: str) -> str:
return _INDENT_RE.match(text).group()
def _without_comment(text: str) -> str:
"""The line up to its comment, which starts at a `#` after whitespace."""
return _COMMENT_RE.sub("", text)
def _file_use_re(file_name: str) -> re.Pattern[str]:
"""The file name at a path boundary; `wifi_api.yaml` is not `api.yaml`."""
return re.compile(rf"(?<![\w.-]){re.escape(file_name)}(?![\w.])")
def _root_indent(lines: list[str]) -> str:
"""The indent of the first content line; a root mapping may be indented."""
content = (
t for t in lines if t.strip() and not t.strip().startswith(("#", "---", "%"))
)
return next((_indent(t) for t in content), "")
def _config_texts() -> tuple[list[tuple[Path, str]], list[str]]:
"""Every yaml file under the config dir with its text, and what could
not be read (a symlinked directory is not walked); walked once per run."""
if (found := CORE.data.get(_DOMAIN)) is None:
found = CORE.data[_DOMAIN] = _walk_config_texts()
return found
def _walk_config_texts() -> tuple[list[tuple[Path, str]], list[str]]:
data_dir = CORE.data_dir.resolve()
found = []
skipped: list[str] = []
def skip(what: object) -> None:
skipped.append(str(what))
_LOGGER.debug("Skipping %s", what)
for dirpath, dirnames, filenames in os.walk(
CORE.config_dir.resolve(), onerror=skip
):
for name in list(dirnames):
path = Path(dirpath, name)
if path == data_dir:
dirnames.remove(name)
elif path.is_symlink():
dirnames.remove(name)
skip(f"{path} is a link")
for filename in filenames:
path = Path(dirpath, filename)
if path.suffix not in _YAML_SUFFIXES:
continue
try:
text = read_text(path)
except EsphomeError as err:
skip(err)
continue
# Searched, never edited: comments do not count
found.append((path, "\n".join(map(_without_comment, text.splitlines()))))
return sorted(found), skipped
def with_sharers(edits: list[KeyEdit]) -> list[str]:
"""Fill in ``shared_with``: the other configs that use each rewritten
line's secret or include, directly or through further includes. Returns
what the scan could not follow (unreadable files, directory and
substituted includes)."""
main = CORE.config_path.resolve()
own = own_documents()
# This config's own includes stay in: another device may reach a line
# through one of them
texts, skipped = _config_texts()
texts = [(path, text) for path, text in texts if path.resolve() != main]
unchecked = [*skipped]
for path, text in texts:
if match := _INCLUDE_DIR_RE.search(text):
unchecked.append(f"{path} includes the directory {match['dir']}")
if match := _SUBSTITUTED_INCLUDE_RE.search(text):
unchecked.append(
f"{path} includes {match['target']} through a substitution"
)
for edit in edits:
if edit.insert_after and edit.secret:
continue # a new secrets line has no users yet
ref = (
_secret_use_re(edit.secret) if edit.secret else _file_use_re(edit.path.name)
)
users = {path for path, text in texts if ref.search(text)}
frontier = users
while frontier:
frontier = {
path
for name in {p.name for p in frontier}
for path, text in texts
if path not in users and _file_use_re(name).search(text)
}
users |= frontier
edit.shared_with = sorted(p for p in users if p.resolve() not in own)
return unchecked
def _secret_file(doc: Path) -> Path:
return editable_file(secrets_path_for(doc))
def _secret_line(
secrets_path: Path, name: str, value: str | None = None
) -> tuple[int, re.Match[str]] | None:
"""The root level ``name:`` line, None when absent."""
lines = read_text(secrets_path).splitlines()
line_re = field_line_re(name, value, re.escape(_root_indent(lines)))
return next(
((i, m) for i, text in enumerate(lines) if (m := line_re.match(text))), None
)
def _secret_rewrite(
secrets_path: Path, name: str, value: str, expect: str | None = None
) -> KeyEdit | None:
"""Set the root level ``name:`` line to ``value``; None when it is not a
plain scalar holding ``expect``."""
if (hit := _secret_line(secrets_path, name, expect)) is None:
return None
i, m = hit
return KeyEdit(secrets_path, i, m.string, rewrite(m, value), secret=name)
def _secret_insert(secrets_path: Path, after: str, name: str, value: str) -> KeyEdit:
"""Add ``name: value`` under the root level ``after`` line."""
if (hit := _secret_line(secrets_path, after)) is None:
raise EsphomeError(
f"No plain '{after}:' line in {secrets_path}; edit it by hand"
)
i, m = hit
return KeyEdit(
secrets_path,
i,
m.string,
f'{_indent(m.string)}{name}: "{value}"',
insert_after=True,
secret=name,
)
def locate_key_edits(old_key: str, new_key: str) -> list[KeyEdit]:
"""The edits that replace the current key; refuses a substitution, flow
mapping or remote package."""
literal_re = field_line_re(CONF_KEY, old_key)
blocks = _key_blocks(CORE.raw_config or {}, old_key)
if not blocks:
raise EsphomeError("The current key was not found on a line of the yaml")
edits: list[KeyEdit] = []
key_lines: set[tuple[Path, int]] = set()
for block in blocks:
doc, line_no, text = source_line(block, CONF_KEY)
key_lines.add((doc.resolve(), line_no))
if match := _KEY_SECRET_RE.match(text):
name, secrets_path = match["name"], _secret_file(doc)
edit = _secret_rewrite(secrets_path, name, new_key, expect=old_key)
if edit is None:
raise EsphomeError(
f"No '{name}:' line with the current key in {secrets_path}"
)
elif match := literal_re.match(text):
edit = KeyEdit(doc, line_no, text, rewrite(match, new_key))
else:
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold the key as a plain value or "
"a !secret; edit the key by hand"
)
# The api and ota blocks may share one !secret line
if edit not in edits:
edits.append(edit)
for edit in edits:
if edit.secret:
_refuse_other_own_uses(edit.secret, key_lines)
return edits
def _refuse_other_own_uses(name: str, allowed: set[tuple[Path, int]]) -> None:
"""The secret may only feed the ``allowed`` lines; a wifi password on it
would change too."""
ref = _secret_use_re(name)
for doc in own_documents():
for i, text in enumerate(read_text(doc).splitlines()):
if ref.search(_without_comment(text)) and (doc, i) not in allowed:
raise EsphomeError(
f"'{name}' is also used at {doc}:{i + 1}; a rotation would "
"change that value too, edit the key by hand"
)
def _secret_name(block: ConfigType) -> str | None:
"""The secrets file name a ``key: !secret <name>`` line of ``block`` holds."""
if source_of(block, CONF_KEY) is None:
return None
match = _KEY_SECRET_RE.match(source_line(block, CONF_KEY)[2])
return match["name"] if match else None
def old_key_edit(old_key: str) -> list[KeyEdit]:
"""Set ``old_key:`` on the esphome ota block, rewriting or adding it. A
key from secrets.yaml stays there as ``<name>_old``. Of several entries
the one with its own old_key or key line gets it."""
items = [
(item, item[CONF_ENCRYPTION] or {})
for item in esphome_ota_items(CORE.raw_config or {})
if CONF_ENCRYPTION in item
]
if not items:
raise EsphomeError("The esphome OTA platform has no 'encryption:' block")
item, encryption = next(
(
pair
for name in (CONF_OLD_KEY, CONF_KEY)
for pair in items
if source_of(pair[1], name) is not None
),
items[0],
)
if (existing := encryption.get(CONF_OLD_KEY)) is not None:
doc, line_no, text = source_line(encryption, CONF_OLD_KEY)
if match := _OLD_KEY_SECRET_RE.match(text):
name, secrets_path = match["name"], _secret_file(doc)
if (edit := _secret_rewrite(secrets_path, name, old_key)) is None:
raise EsphomeError(
f"No plain '{name}:' line in {secrets_path}; edit it by hand"
)
_refuse_other_own_uses(name, {(doc.resolve(), line_no)})
return [edit]
match = field_line_re(CONF_OLD_KEY, str(existing)).match(text)
if match is None:
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold '{CONF_OLD_KEY}' as a plain "
"value; edit it by hand"
)
return [KeyEdit(doc, line_no, text, rewrite(match, old_key))]
# Indent from the block's own key line, else one level under `encryption:`
if source_of(encryption, CONF_KEY) is not None:
mapping, anchor, anchor_re, step = encryption, CONF_KEY, _KEY_FIELD_RE, ""
else:
mapping, anchor, anchor_re, step = item, CONF_ENCRYPTION, _BARE_BLOCK_RE, " "
doc, line_no, text = source_line(mapping, anchor)
if not anchor_re.match(text):
raise EsphomeError(
f"{doc}:{line_no + 1} does not hold '{anchor}:' as a block line; "
"edit it by hand"
)
# The block's own key line decides; a bare block takes after the api key
keyed = encryption if mapping is encryption else _api_encryption()
if (secret := _secret_name(keyed)) is None:
rendered = f'{CONF_OLD_KEY}: "{old_key}"'
extra = []
else:
rendered = f"{CONF_OLD_KEY}: !secret {secret}_old"
secrets_path = _secret_file(doc) # the file that gets old_key
# An existing `<secret>_old` line is reused only when nothing uses it
if (
kept := _secret_rewrite(secrets_path, f"{secret}_old", old_key)
) is not None:
ref = _secret_use_re(f"{secret}_old")
texts, skipped = _config_texts()
if skipped: # an unread file might use the line
raise EsphomeError(
"Could not read every configuration to check the secret: "
+ skipped[0]
)
if users := [p for p, t in texts if ref.search(t)]:
raise EsphomeError(
f"'{secret}_old:' in {secrets_path} is used by "
+ ", ".join(str(p) for p in users)
+ f"; rename it or add '{CONF_OLD_KEY}' by hand"
)
extra = [kept or _secret_insert(secrets_path, secret, f"{secret}_old", old_key)]
edit = KeyEdit(
doc, line_no, text, f"{_indent(text)}{step}{rendered}", insert_after=True
)
return [edit, *extra]
+17 -218
View File
@@ -9,8 +9,7 @@ from pathlib import Path
import re
import stat
from esphome import compiled_config, yaml_util
from esphome.core import CORE, EsphomeError
from esphome.core import EsphomeError
from esphome.helpers import write_file
from esphome.types import ConfigType
@@ -21,10 +20,6 @@ PLAIN_SCALAR = r"[^\s#\"'>|&*!%@`\[\]{},]+"
TRAILER = r"(?P<quote>[\"']?){value}(?P=quote)(?P<trail>(?:\s+#.*)?\s*)$"
class RestoreError(EsphomeError):
"""A rollback left at least one file as it was written."""
def read_text(path: Path) -> str:
"""The file as written, line endings included; read_file would fold them."""
try:
@@ -35,25 +30,12 @@ def read_text(path: Path) -> str:
@dataclass
class LineEdit:
"""One line to rewrite, or with ``insert_after`` a line to add right
after it; ``old_line`` is what the line held when it was located."""
"""One line to rewrite; ``old_line`` is what it held when located."""
path: Path
line: int
old_line: str
new_line: str
insert_after: bool = False
@dataclass
class Snapshot:
"""A rewritten file's text before and after, and its mode before; the
restore only puts ``original`` back over ``written``, never over the
user's own edit."""
original: str
written: str
mode: int | None = None
def field_line_re(
@@ -81,66 +63,17 @@ def line_at(doc: Path, line_no: int) -> str:
return lines[line_no]
_DOMAIN = "yaml_edit"
def own_documents() -> set[Path]:
"""Every file the loader read for this configuration, found once per run.
A file behind an include path with a substitution that gave nothing but
substitutions is the one kind this misses."""
if (documents := CORE.data.get(_DOMAIN)) is None:
documents = CORE.data[_DOMAIN] = _own_documents()
return documents
def _own_documents() -> set[Path]:
documents = {str(CORE.config_path)}
def walk(node: object) -> None:
if (rng := getattr(node, "esp_range", None)) is not None:
documents.add(rng.start_mark.document)
if isinstance(node, dict):
for key, value in node.items():
walk(key)
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(CORE.raw_config or {})
# A file that only gave substitutions leaves no node behind; the tracker
# also lists a secrets file the loader only tried, hence the is_file check
found = yaml_util.discover_user_yaml_files(CORE.config_path, clear_secrets=False)
if found.load_errors: # an incomplete set would misplace a key
raise EsphomeError(
"Could not read every file of the configuration: "
+ "; ".join(found.load_errors)
)
documents.update(map(str, found.files))
return {p.resolve() for d in documents if (p := Path(d)).is_file()}
def source_of(mapping: ConfigType, name: str) -> tuple[Path, int] | None:
"""The file and line ``name:`` was read from; None when validation added
it or a merge key brought it in from an anchor elsewhere."""
"""The file and line ``name:`` was read from, None when validation added
it or a merge key brought it in from an anchor elsewhere. Mapping keys
keep their range through validation, values may not."""
rng = getattr(next((k for k in mapping if k == name), None), "esp_range", None)
if rng is None:
return None
# An included mapping carries the `!include` line of its parent, so only
# a key in the same document can be placed against the mapping
if (own := getattr(mapping, "esp_range", None)) is None:
# merge_config keeps the first mapping's keys with the last one's
# values, so only the value's range names the line that won, and
# only when the key is on it (a block value starts on a line of its own)
rng = getattr(mapping.get(name), "esp_range", None)
if rng is None:
return None
doc, line = Path(rng.start_mark.document), rng.start_mark.line
if doc.resolve() not in own_documents() or not field_line_re(name).match(
line_at(doc, line)
):
return None
return doc, line
# An included mapping carries its parent's `!include` line, so only a
# key in the same document can be checked against the mapping's lines
return None # a mapping built in code, its keys are not on its lines
if (
rng.start_mark.document == own.start_mark.document
and not own.start_mark.line <= rng.start_mark.line <= own.end_mark.line
@@ -149,46 +82,13 @@ def source_of(mapping: ConfigType, name: str) -> tuple[Path, int] | None:
return Path(rng.start_mark.document), rng.start_mark.line
def editable_file(path: Path) -> Path:
"""``path`` resolved, so a symlink's target is edited; refuses a file
outside the config dir or under the build data."""
resolved = path.resolve()
if (
not path.is_file()
or not resolved.is_relative_to(CORE.config_dir.resolve())
or resolved.is_relative_to(CORE.data_dir.resolve())
):
raise EsphomeError(
f"{path} is not an editable file in the configuration directory"
)
return resolved
def source_line(mapping: ConfigType, name: str) -> tuple[Path, int, str]:
"""The file, line number and text ``name:`` was read from."""
if (source := source_of(mapping, name)) is None:
raise EsphomeError(f"'{name}' was not read from a file")
doc, line_no = source
# Returned as the loader saw it: a `!secret` in a symlinked include
# resolves beside the link
editable_file(doc)
return doc, line_no, line_at(doc, line_no)
def mode_of(path: Path) -> int:
def write_keeping_mode(path: Path, text: str, like: Path | None = None) -> None:
"""Write with the mode of ``like`` (default: the file itself) rather
than write_file's 0644; a 0600 secrets file stays 0600."""
try:
return stat.S_IMODE(path.stat().st_mode)
mode = stat.S_IMODE((like or path).stat().st_mode)
except OSError as err:
raise EsphomeError(f"Could not read the mode of {path}: {err}") from err
def write_keeping_mode(
path: Path, text: str, like: Path | None = None, mode: int | None = None
) -> None:
"""Write with ``mode``, else the mode of ``like`` (default: the file
itself), rather than write_file's 0644; a 0600 secrets file stays 0600."""
if mode is None:
mode = mode_of(like or path)
raise EsphomeError(f"Could not read the mode of {like or path}: {err}") from err
try:
write_file(path, text, private=True)
except EsphomeError as err:
@@ -203,113 +103,12 @@ def write_keeping_mode(
def rewritten_text(original: str, edits: list[LineEdit]) -> str:
"""``original`` with the edits applied; a line that changed is refused."""
"""``original`` with the edits applied; every edit must still find the
line it was located on."""
lines = original.splitlines(keepends=True)
file_newline = "\r\n" if "\r\n" in original else "\n"
# Bottom up, so an insertion never shifts a later edit; insertions under
# one line keep their order
order = sorted(
enumerate(edits), key=lambda ie: (ie[1].line, ie[1].insert_after, ie[0])
)
for _, edit in reversed(order):
for edit in edits:
text = lines[edit.line].rstrip("\r\n") if edit.line < len(lines) else None
if text != edit.old_line:
raise EsphomeError(f"{edit.path}:{edit.line + 1} changed since it was read")
ending = lines[edit.line][len(text) :]
if edit.insert_after:
# A last line without a newline gets the file's own kind
lines[edit.line] = text + (ending or file_newline)
lines.insert(edit.line + 1, edit.new_line + ending)
else:
lines[edit.line] = edit.new_line + ending
lines[edit.line] = edit.new_line + lines[edit.line][len(text) :]
return "".join(lines)
def apply_line_edits(edits: list[LineEdit]) -> dict[Path, Snapshot]:
"""Rewrite the located lines in place; returns each file's text before
and after for restore_files. Nothing is written while any line is stale,
and a file that no longer loads is put back here."""
by_path: dict[Path, list[LineEdit]] = {}
seen_as: dict[Path, Path] = {} # the path the loader read a file by
for edit in edits:
resolved = editable_file(edit.path)
by_path.setdefault(resolved, []).append(edit)
seen_as.setdefault(resolved, edit.path)
originals = {}
for path, own in by_path.items():
original = read_text(path)
originals[path] = Snapshot(
original, rewritten_text(original, own), mode_of(path)
)
try:
for path, snapshot in originals.items():
write_keeping_mode(path, snapshot.written)
# Reloaded as the loader saw them: a relative !include inside a
# symlinked file resolves beside the link
reloads = [seen_as[path] for path in originals]
if CORE.config_path.resolve() not in originals:
# Its !secret tags register a value written elsewhere
reloads.append(CORE.config_path)
for path in reloads:
try:
# The secret registry stays: later output still redacts
yaml_util.load_yaml(
path, clear_secrets=False, track_document_range=False
)
except EsphomeError as err:
raise EsphomeError(f"{path} no longer loads: {err}") from err
compiled_config.invalidate_compiled_config()
except BaseException as err:
try:
restore_files(originals)
except RestoreError as restore_err:
if not isinstance(err, Exception):
err.add_note(str(restore_err)) # an interrupt stays one
raise err from None
raise RestoreError(f"{err}; {restore_err}") from err
raise
return originals
def restore_files(originals: dict[Path, Snapshot]) -> None:
"""Put back what apply_line_edits wrote, mode included; a file that
failed or changed meanwhile is left alone and reported. Every file is
tried whatever went wrong before; an interrupt or exit is raised again
at the end with the report as a note."""
failed: list[str] = []
stop: BaseException | None = None
def report(what: str, err: BaseException) -> None:
nonlocal stop
if isinstance(err, Exception):
why = str(err) or type(err).__name__
else: # an interrupt or exit says what it is, not its code
why = (
"interrupted"
if isinstance(err, KeyboardInterrupt)
else type(err).__name__
)
stop = err
failed.append(f"{what}: {why}")
for path, snapshot in originals.items():
try:
current = read_text(path)
if current == snapshot.original:
continue # never written, or already put back
if current != snapshot.written:
raise EsphomeError("changed since it was written, left as is")
write_keeping_mode(path, snapshot.original, mode=snapshot.mode)
except BaseException as err: # noqa: BLE001 # pylint: disable=broad-except
report(str(path), err)
try:
compiled_config.invalidate_compiled_config()
except BaseException as err: # noqa: BLE001 # pylint: disable=broad-except
report("the validated caches", err)
if not failed:
return
message = "Could not restore " + "; ".join(failed)
if stop is not None:
stop.add_note(message)
raise stop
raise RestoreError(message)
+5 -21
View File
@@ -637,13 +637,9 @@ class DiscoveredYamlFiles:
load_errors: list[str] = field(default_factory=list)
def discover_user_yaml_files(
config_path: Path, clear_secrets: bool = True
) -> DiscoveredYamlFiles:
def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles:
"""Fresh-re-parse ``config_path`` and report every file the YAML loader
pulled in, plus which of them came in under a secrets filename.
``clear_secrets`` is passed to the load; False keeps the redaction
registry of a configuration loaded earlier in the run.
Does NOT run schema validation, substitutions, or package resolution — so
component-internal YAML loaded by validators (LVGL helpers, dashboard
@@ -667,7 +663,7 @@ def discover_user_yaml_files(
_load_listeners.append(_capture_secret)
try:
try:
data = load_yaml(config_path, clear_secrets=clear_secrets)
data = load_yaml(config_path)
except EsphomeError as err:
return DiscoveredYamlFiles(
list(loaded), secrets, load_errors=[f"{config_path}: {err}"]
@@ -926,7 +922,9 @@ class ESPHomeLoaderMixin:
if self.name == CORE.config_path:
raise e
try:
secrets = self.yaml_loader(CORE.config_path.parent / SECRET_YAML)
main_config_dir = CORE.config_path.parent
main_secret_yml = main_config_dir / SECRET_YAML
secrets = self.yaml_loader(main_secret_yml)
except EsphomeError as er:
raise EsphomeError(f"{e}\n{er}") from er
@@ -1299,20 +1297,6 @@ def find_files(directory: Path, pattern: str) -> Iterator[Path]:
yield filename
def secrets_path_for(document: Path) -> Path:
"""The secrets.yaml a ``!secret`` in ``document`` resolves against: the
one beside the document when it loads, else the main config's, the
way construct_secret falls back."""
beside = document.parent / SECRET_YAML
if document == CORE.config_path:
return beside
try:
load_yaml(beside, clear_secrets=False, track_document_range=False)
except EsphomeError:
return CORE.config_path.parent / SECRET_YAML
return beside
def is_secret(value):
try:
return _SECRET_VALUES[str(value)]
@@ -0,0 +1,196 @@
#include "esphome/core/defines.h"
#if defined(USE_API_PLAINTEXT) && defined(USE_API_HOMEASSISTANT_STATES) && defined(USE_SENSOR)
#include <benchmark/benchmark.h>
#include <sys/socket.h>
#include <array>
#include <memory>
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_server.h"
#include "esphome/components/homeassistant/sensor/homeassistant_sensor.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant::benchmarks {
static constexpr int kInnerIterations = 2000;
// Each benchmark gets its own API server so subscriptions from one do not
// stay in the list scanned by the next. The constructor installs itself as
// global_api_server, which is what HomeassistantSensor::setup() subscribes to.
class ScopedApiServer {
public:
ScopedApiServer() : previous_(api::global_api_server) {}
~ScopedApiServer() { api::global_api_server = this->previous_; }
api::APIServer *get() { return &this->server_; }
protected:
api::APIServer *previous_;
api::APIServer server_;
};
// The handler only reads the server's subscription list, so the connection is
// never started and its socket never used.
static std::unique_ptr<api::APIConnection> create_connection(api::APIServer *server) {
const int fd = ::socket(AF_INET, SOCK_STREAM, 0);
return std::make_unique<api::APIConnection>(std::make_unique<socket::Socket>(fd), server);
}
// State strings as Home Assistant sends them. The handler writes a terminator
// one byte past the state, so the buffers are mutable and the terminator it
// writes lands on the one the literal already has.
static constexpr int kStateCount = 4;
static constexpr size_t kStateBufferSize = 16;
using StateBuffers = char[kStateCount][kStateBufferSize];
using Messages = std::array<api::HomeAssistantStateResponse, kStateCount>;
static StateBuffers NUMERIC_STATES = {"100.50", "101.25", "99.75", "100.00"};
static StateBuffers INTEGER_STATES = {"100", "101", "99", "0"};
static StateBuffers UNAVAILABLE_STATES = {"unavailable", "unknown", "unavailable", "unknown"};
static Messages make_messages(const char *entity_id, const char *attribute, StateBuffers &states) {
Messages msgs;
for (int i = 0; i < kStateCount; i++) {
msgs[i].entity_id = StringRef(entity_id);
if (attribute != nullptr) {
msgs[i].attribute = StringRef(attribute);
}
msgs[i].state = StringRef(states[i]);
}
return msgs;
}
// --- One imported sensor, numeric state that changes every message ---
// Exercises: subscription match, in-place terminator, parse_number, publish_state.
static void HomeassistantSensor_StateResponse(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("sensor.power", nullptr, NUMERIC_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("sensor.power");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse);
// --- The same sensor with integer states such as a percentage or a count ---
// Exercises: the same path with no fractional digits to parse. Only the state
// differs from the decimal case, so the gap between the two is the parse.
static void HomeassistantSensor_StateResponse_Integer(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("sensor.power", nullptr, INTEGER_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("sensor.power");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_Integer);
// --- Attribute import: the subscription and the message both carry an attribute ---
// Exercises: the extra attribute compare before the callback.
static void HomeassistantSensor_StateResponse_Attribute(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("climate.living_room", "current_temperature", NUMERIC_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("climate.living_room");
sensor.set_attribute("current_temperature");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_Attribute);
// --- Sixteen imported sensors, message for the last one ---
// Exercises: the linear scan over the subscription list with a string compare
// per entry, which is what a device with many imported entities pays per update.
// The IDs share a length and differ only in the last two bytes, so every
// entry runs a near-full compare, the worst case for the scan.
static constexpr int kSensorCount = 16;
static void HomeassistantSensor_StateResponse_ManySubscriptions(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
static constexpr const char *const ENTITY_IDS[kSensorCount] = {
"sensor.import_00", "sensor.import_01", "sensor.import_02", "sensor.import_03",
"sensor.import_04", "sensor.import_05", "sensor.import_06", "sensor.import_07",
"sensor.import_08", "sensor.import_09", "sensor.import_10", "sensor.import_11",
"sensor.import_12", "sensor.import_13", "sensor.import_14", "sensor.import_15",
};
HomeassistantSensor sensors[kSensorCount];
for (int i = 0; i < kSensorCount; i++) {
sensors[i].set_entity_id(ENTITY_IDS[i]);
sensors[i].setup();
}
const Messages msgs = make_messages(ENTITY_IDS[kSensorCount - 1], nullptr, NUMERIC_STATES);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensors[kSensorCount - 1].state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_ManySubscriptions);
// --- Non-numeric state such as "unavailable" ---
// Exercises: the failed parse, the warning the platform logs for it, and the
// NAN publish. The benchmark logger runs at the production default level with
// console output off, so a log line at DEBUG or above costs its formatting
// here, the same as on a device, and a log level change in the platform shows
// up as a delta.
static void HomeassistantSensor_StateResponse_Unavailable(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("sensor.power", nullptr, UNAVAILABLE_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("sensor.power");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_Unavailable);
} // namespace esphome::homeassistant::benchmarks
#endif // USE_API_PLAINTEXT && USE_API_HOMEASSISTANT_STATES && USE_SENSOR
@@ -0,0 +1,3 @@
# The homeassistant platforms subscribe through the API server.
api:
sensor:
@@ -0,0 +1,13 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# In production the platform's to_code emits this define; the host build
# suppresses that to_code, and the API server needs the define for the
# state subscription path the benchmarks drive.
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_STATES")
manifest.to_code = to_code
@@ -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
@@ -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

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