mirror of
https://github.com/esphome/esphome.git
synced 2026-10-15 02:32:49 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e01b244c5f | ||
|
|
0dc1d3b5a1 | ||
|
|
017e379c87 | ||
|
|
42341d05f9 | ||
|
|
f941fee5ed | ||
|
|
8721976066 | ||
|
|
05bf7d3440 | ||
|
|
476b442cd0 | ||
|
|
49ab7d058a | ||
|
|
918b7d2553 | ||
|
|
417ab4d2e9 | ||
|
|
0821c84b7a | ||
|
|
a827058928 | ||
|
|
3063b76210 | ||
|
|
55261ae7e5 | ||
|
|
1d8423edd8 | ||
|
|
d27ec8ec49 | ||
|
|
5acbd44067 | ||
|
|
40271fb597 | ||
|
|
95766ed3ad | ||
|
|
4a80d785a8 | ||
|
|
3f7ec21717 | ||
|
|
b89f20cec5 | ||
|
|
fa04972a26 | ||
|
|
99b5d4a35a |
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]))
|
||||
@@ -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))
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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_); }
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
|
||||
@@ -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 ¶ms = 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();
|
||||
|
||||
@@ -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_),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user