mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 18:22:48 +00:00
Compare commits
327
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c83ea861eb | ||
|
|
3cdcf0417a | ||
|
|
c6e90de262 | ||
|
|
92f6ab8367 | ||
|
|
f61955baa2 | ||
|
|
36e1d831d3 | ||
|
|
63616bf642 | ||
|
|
d275926f65 | ||
|
|
f265c46c21 | ||
|
|
7d735f0677 | ||
|
|
5c23a827b6 | ||
|
|
e265c0c0fc | ||
|
|
a0c7d754cb | ||
|
|
a6dbd72c53 | ||
|
|
9d530f5c56 | ||
|
|
6562c49686 | ||
|
|
a97ce3a201 | ||
|
|
5a78f636b9 | ||
|
|
53d504137b | ||
|
|
bb0546fc0b | ||
|
|
b43630cf8e | ||
|
|
c9664d4e42 | ||
|
|
39ea6a3220 | ||
|
|
f4a6d6f18b | ||
|
|
6c24d4aa43 | ||
|
|
0dc1d3b5a1 | ||
|
|
42341d05f9 | ||
|
|
f941fee5ed | ||
|
|
8721976066 | ||
|
|
05bf7d3440 | ||
|
|
955afc4847 | ||
|
|
918b7d2553 | ||
|
|
417ab4d2e9 | ||
|
|
0821c84b7a | ||
|
|
a827058928 | ||
|
|
3063b76210 | ||
|
|
55261ae7e5 | ||
|
|
1d8423edd8 | ||
|
|
d27ec8ec49 | ||
|
|
5acbd44067 | ||
|
|
40271fb597 | ||
|
|
95766ed3ad | ||
|
|
4a80d785a8 | ||
|
|
c1c1d2b2ae | ||
|
|
29a668af04 | ||
|
|
fd541bca2c | ||
|
|
192ae31e45 | ||
|
|
77c9fa2fa4 | ||
|
|
25843c5242 | ||
|
|
7881dfc5d8 | ||
|
|
e289a94451 | ||
|
|
5017165800 | ||
|
|
8acf885241 | ||
|
|
f829447b2e | ||
|
|
74e96b7da6 | ||
|
|
753c10e5ea | ||
|
|
7a01c71022 | ||
|
|
9f254c69a1 | ||
|
|
1b6cc0bf18 | ||
|
|
0801211ba1 | ||
|
|
5a2f059be7 | ||
|
|
14998c0905 | ||
|
|
d69a1e9bf9 | ||
|
|
8eb5d17f3d | ||
|
|
808afc024d | ||
|
|
0bb98b715f | ||
|
|
90af765fae | ||
|
|
f17b18d51a | ||
|
|
5f11c430cc | ||
|
|
baf57195e2 | ||
|
|
27a97f36af | ||
|
|
0a28a5b023 | ||
|
|
5c6e62ffe0 | ||
|
|
0801b29617 | ||
|
|
b00ad7ca8a | ||
|
|
7ad51b4b8f | ||
|
|
65c1b06f7f | ||
|
|
e158205e4c | ||
|
|
f5debdad32 | ||
|
|
2a95fa44dc | ||
|
|
314557f83b | ||
|
|
e4e464a3b9 | ||
|
|
a41dbed69c | ||
|
|
ba10832ed7 | ||
|
|
d6b35f8ccf | ||
|
|
03efcf6bb5 | ||
|
|
0d46b1047e | ||
|
|
456f5b3042 | ||
|
|
83c9603b4b | ||
|
|
ad9a41ed61 | ||
|
|
e6b7b3a028 | ||
|
|
9a4a550fb9 | ||
|
|
42dc4025c9 | ||
|
|
901fd35a7e | ||
|
|
4344ffd682 | ||
|
|
1c671be1bd | ||
|
|
52dc51c7c6 | ||
|
|
671b6216a7 | ||
|
|
5764ca607d | ||
|
|
273c80ecd3 | ||
|
|
1933126ad7 | ||
|
|
64034b5b23 | ||
|
|
830854d661 | ||
|
|
87b7076cd1 | ||
|
|
5ec7047151 | ||
|
|
45d921f77a | ||
|
|
321b00143d | ||
|
|
e6e9016ed4 | ||
|
|
a292aa2654 | ||
|
|
4353f5ac9c | ||
|
|
4925f6648b | ||
|
|
47bd8f5ffc | ||
|
|
31771da92c | ||
|
|
1978f203bc | ||
|
|
ae906ada94 | ||
|
|
6a2465da63 | ||
|
|
9a5aab63d5 | ||
|
|
deeec17d0d | ||
|
|
735f810f08 | ||
|
|
cd48023a56 | ||
|
|
9330204908 | ||
|
|
45501056d8 | ||
|
|
2e6ad94bd1 | ||
|
|
5994105d34 | ||
|
|
0ac9a0f8bd | ||
|
|
77b8b384bd | ||
|
|
3728669fa6 | ||
|
|
578e4774a9 | ||
|
|
8696255ccc | ||
|
|
594931265b | ||
|
|
b0f753a7a2 | ||
|
|
fc69d8543e | ||
|
|
d586a6c8d4 | ||
|
|
9301391792 | ||
|
|
1b801427c4 | ||
|
|
be391feaa9 | ||
|
|
7e369f332c | ||
|
|
a40ecfdf9c | ||
|
|
9a6765eb8f | ||
|
|
1d0a87daa8 | ||
|
|
c98dd74e11 | ||
|
|
aceff27948 | ||
|
|
211337cd6d | ||
|
|
4276815734 | ||
|
|
3c57064bf2 | ||
|
|
f9be82d1a6 | ||
|
|
10a0bf0ff8 | ||
|
|
484674b85d | ||
|
|
49c1547fda | ||
|
|
7b4353b417 | ||
|
|
5f20346d54 | ||
|
|
356c1d9134 | ||
|
|
9534a1df55 | ||
|
|
fb64b18047 | ||
|
|
5572bf77dd | ||
|
|
d8ec76e02b | ||
|
|
cf5faa454c | ||
|
|
55d14c6fbf | ||
|
|
d86bdb36cf | ||
|
|
c64684dd00 | ||
|
|
ed002feee9 | ||
|
|
38f31d85b6 | ||
|
|
d04ef2378e | ||
|
|
5e26633231 | ||
|
|
6c78b480c4 | ||
|
|
3d33a16b71 | ||
|
|
e3aa8773e6 | ||
|
|
be5e00aefd | ||
|
|
9c5ebdffc9 | ||
|
|
9554e1a212 | ||
|
|
28491f83b4 | ||
|
|
2f71ec16d7 | ||
|
|
3daa1d2450 | ||
|
|
a513cef4e2 | ||
|
|
fa17215053 | ||
|
|
073d9a72a0 | ||
|
|
3a24d0b03b | ||
|
|
b229bd4d8a | ||
|
|
c8e240198d | ||
|
|
0512339c58 | ||
|
|
e95bebb7d0 | ||
|
|
084ff9d6e3 | ||
|
|
4a9e5f5da9 | ||
|
|
f6158597ac | ||
|
|
8924640593 | ||
|
|
26c000c417 | ||
|
|
7cb32f9546 | ||
|
|
13de0275c8 | ||
|
|
22af86a65b | ||
|
|
83b099d708 | ||
|
|
6957e2ef15 | ||
|
|
743a10fa3e | ||
|
|
f7b1e9ae65 | ||
|
|
53a5c7b051 | ||
|
|
54c50c07c5 | ||
|
|
64df0c1da1 | ||
|
|
9c31e08e95 | ||
|
|
93124f1f1f | ||
|
|
8abb5f102d | ||
|
|
12fb1b0697 | ||
|
|
81216da8e7 | ||
|
|
ed2cdad9b4 | ||
|
|
be818a5bf2 | ||
|
|
444a217ee7 | ||
|
|
93f9d4a1ad | ||
|
|
ac7d39a8c1 | ||
|
|
612033062f | ||
|
|
f9dbd24a2a | ||
|
|
833a835e0f | ||
|
|
399dc26236 | ||
|
|
0d9aea9594 | ||
|
|
f2261d6632 | ||
|
|
d224a82e38 | ||
|
|
ce436ff8b4 | ||
|
|
cbda1e71be | ||
|
|
ceabfeb6f4 | ||
|
|
5e11a886f1 | ||
|
|
e771b2522a | ||
|
|
ee96293c4c | ||
|
|
fa18f5a0ad | ||
|
|
269d52ac2e | ||
|
|
06a52f682f | ||
|
|
409ff027cc | ||
|
|
3c7e5000a5 | ||
|
|
7af211ab54 | ||
|
|
cc81069d02 | ||
|
|
ba567cecc3 | ||
|
|
ee7fb83334 | ||
|
|
9db31db24d | ||
|
|
ceca66c01a | ||
|
|
dc97c5a161 | ||
|
|
97bbcf55d9 | ||
|
|
d135ac97e5 | ||
|
|
296dd19361 | ||
|
|
138304cfbe | ||
|
|
9fef1862be | ||
|
|
80758f11aa | ||
|
|
9d8362738f | ||
|
|
b2c22b5802 | ||
|
|
f62009ecc9 | ||
|
|
c09e3e0d45 | ||
|
|
cdd7bef74a | ||
|
|
0e1321a813 | ||
|
|
022b612366 | ||
|
|
2dc9fbec5e | ||
|
|
9dafff6a61 | ||
|
|
3c7a1062c8 | ||
|
|
8164cdd018 | ||
|
|
4ea491d46f | ||
|
|
7fdb6cd2d5 | ||
|
|
2d50e07e90 | ||
|
|
78a451a1c0 | ||
|
|
2483621500 | ||
|
|
4f086b6b25 | ||
|
|
c4850a9320 | ||
|
|
91bcb14c09 | ||
|
|
85e4a6cc33 | ||
|
|
149d7cd801 | ||
|
|
ce2e113a6d | ||
|
|
ae5b6d271f | ||
|
|
e1572d7520 | ||
|
|
536afce394 | ||
|
|
152b6a5d50 | ||
|
|
f68ebcf7d1 | ||
|
|
ca5fa6dfaf | ||
|
|
962a82d157 | ||
|
|
c6d4ca7377 | ||
|
|
465a6a3596 | ||
|
|
03c9b001f4 | ||
|
|
d680200e5a | ||
|
|
98dae49440 | ||
|
|
7ec366c1ba | ||
|
|
b1e1b0d7c5 | ||
|
|
ec49eb12dd | ||
|
|
949474efc6 | ||
|
|
708f095b6f | ||
|
|
bf4af703b6 | ||
|
|
541616e19c | ||
|
|
952c3e6584 | ||
|
|
2ea55bee7b | ||
|
|
1931ef32ee | ||
|
|
f2da6c1a56 | ||
|
|
6e9e83bdfd | ||
|
|
fe29a53b71 | ||
|
|
fa2dafcbca | ||
|
|
dc18fced19 | ||
|
|
389d3629c2 | ||
|
|
36a4d87611 | ||
|
|
65db614f22 | ||
|
|
591856e7dc | ||
|
|
b680482092 | ||
|
|
7fe447a4a2 | ||
|
|
8fae4f6767 | ||
|
|
3d458bd9c8 | ||
|
|
fd7510d6e5 | ||
|
|
ffe55f7691 | ||
|
|
40ac344bf2 | ||
|
|
667ffe98d9 | ||
|
|
270f776675 | ||
|
|
965bbd4425 | ||
|
|
e485e5af90 | ||
|
|
c795d0734d | ||
|
|
0e77d47df4 | ||
|
|
47c2f8f31a | ||
|
|
fe53d24e46 | ||
|
|
daf988fde8 | ||
|
|
6604a9cca7 | ||
|
|
ddd45e393b | ||
|
|
0974e8a1f5 | ||
|
|
1478307280 | ||
|
|
fc77cdcb7d | ||
|
|
fcb9da489e | ||
|
|
ab458fffab | ||
|
|
c0c3afe6b1 | ||
|
|
b092f64744 | ||
|
|
6b2938c1f7 | ||
|
|
0e5d902c95 | ||
|
|
11fc8e4c07 | ||
|
|
d9f0f40373 | ||
|
|
e671a602e9 | ||
|
|
7f44b9b826 | ||
|
|
47804f8d08 | ||
|
|
67cc43c8ef | ||
|
|
3e9808162b | ||
|
|
a6f81f3185 | ||
|
|
5296539849 | ||
|
|
de9074fb5e |
@@ -144,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
|
||||
* **C++ Preprocessor Directives:**
|
||||
* **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums.
|
||||
* **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope
|
||||
and inside functions; `static const` is only for a value computed at run time. A function-local
|
||||
`static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too.
|
||||
* **Use `#define` only for:**
|
||||
- Conditional compilation (`#ifdef`, `#ifndef`)
|
||||
- Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`)
|
||||
@@ -217,6 +220,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
listener base (a second vptr per entity).
|
||||
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
|
||||
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
|
||||
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
|
||||
(`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check
|
||||
stays inside the `#ifdef`.
|
||||
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
|
||||
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
|
||||
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
|
||||
@@ -567,6 +573,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
|
||||
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
|
||||
that rejects keys and fills defaults afterwards.
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
@@ -609,6 +618,9 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
|
||||
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
|
||||
on. A variant per board for platform-identical code only adds CI time.
|
||||
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
|
||||
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
|
||||
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
|
||||
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
|
||||
```
|
||||
tests/
|
||||
|
||||
@@ -106,6 +106,7 @@ esphome/components/bp1658cj/* @Cossid
|
||||
esphome/components/bp5758d/* @Cossid
|
||||
esphome/components/bq25186/* @Rapsssito
|
||||
esphome/components/bq27220/* @rggammon
|
||||
esphome/components/bq76972/* @limpkin
|
||||
esphome/components/bridge/* @kbx81
|
||||
esphome/components/bthome_mithermometer/* @nagyrobi
|
||||
esphome/components/button/* @esphome/core
|
||||
@@ -211,8 +212,10 @@ 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
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
esphome/components/ft5x06/* @clydebarrow
|
||||
esphome/components/ft63x6/* @gpambrozio
|
||||
@@ -240,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
|
||||
@@ -284,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
|
||||
@@ -432,6 +439,7 @@ esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
|
||||
esphome/components/pcf85063/* @brogon
|
||||
esphome/components/pcf8563/* @KoenBreeman
|
||||
|
||||
+16
-18
@@ -483,12 +483,9 @@ def has_ota() -> bool:
|
||||
|
||||
def has_native_ota() -> bool:
|
||||
"""Check if native API OTA upload is available (``platform: esphome``)."""
|
||||
if CONF_OTA not in CORE.config:
|
||||
return False
|
||||
return any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
for ota_item in CORE.config[CONF_OTA]
|
||||
)
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
|
||||
return bool(esphome_ota_items(CORE.config))
|
||||
|
||||
|
||||
def has_web_server_ota() -> bool:
|
||||
@@ -1359,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
return CONF_WEB_SERVER
|
||||
|
||||
|
||||
def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
|
||||
"""The validated esphome OTA platform block, if configured."""
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
|
||||
return next(iter(esphome_ota_items(config)), None)
|
||||
|
||||
|
||||
def _upload_via_native_api(
|
||||
config: ConfigType, network_devices: list[str], args: ArgsProtocol
|
||||
) -> tuple[int, str | None]:
|
||||
ota_conf: ConfigType = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
|
||||
ota_conf = ota_item
|
||||
break
|
||||
ota_conf = _esphome_ota_conf(config) or {}
|
||||
|
||||
from esphome import espota2
|
||||
from esphome.components.noise import static_encryption_key
|
||||
@@ -1457,10 +1457,8 @@ def _old_key_notice(config: ConfigType) -> None:
|
||||
`upload` may send a stale image that still carries the previous key."""
|
||||
from esphome import espota2
|
||||
|
||||
for item in config.get(CONF_OTA, []):
|
||||
if item.get(CONF_PLATFORM) == CONF_ESPHOME and (
|
||||
item.get(CONF_ENCRYPTION) or {}
|
||||
).get(espota2.CONF_OLD_KEY):
|
||||
for item in espota2.esphome_ota_items(config):
|
||||
if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY):
|
||||
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
|
||||
|
||||
|
||||
@@ -1468,12 +1466,12 @@ def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
from esphome import web_server_ota
|
||||
from esphome.espota2 import esphome_ota_items
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
if any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
and ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in config.get(CONF_OTA, [])
|
||||
ota_item.get(CONF_ENCRYPTION) is not None
|
||||
for ota_item in esphome_ota_items(config)
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"This config has OTA encryption, but the web_server OTA path sends "
|
||||
|
||||
+16
-1
@@ -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,10 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <set>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bedjet {
|
||||
|
||||
static const char *const TAG = "bedjet";
|
||||
ESPHOME_LOG_TAG(TAG, "bedjet");
|
||||
|
||||
/// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%.
|
||||
inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
from esphome.components import ble_device_base, bluetooth_connection
|
||||
from esphome.components.ble_device_base import (
|
||||
BT_UUID16_FORMAT as bt_uuid16_format,
|
||||
BT_UUID32_FORMAT as bt_uuid32_format,
|
||||
BT_UUID128_FORMAT as bt_uuid128_format,
|
||||
as_hex,
|
||||
as_reversed_hex_array,
|
||||
bt_uuid,
|
||||
)
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHARACTERISTIC_UUID,
|
||||
@@ -15,15 +30,55 @@ from esphome.const import (
|
||||
CONF_SERVICE_UUID,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_VALUE,
|
||||
PLATFORM_ESP32,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client"]
|
||||
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily inside
|
||||
# the esp32 schema/codegen arms: importing those modules registers esp32-only
|
||||
# automations as a side effect, which must not leak into the neutral
|
||||
# platforms' registries (the bluetooth_proxy pattern).
|
||||
|
||||
|
||||
def _legacy_engine() -> bool:
|
||||
"""True when the build uses the legacy raw-gattc engine - one line to
|
||||
flip when esp32 moves to the neutral engine (with
|
||||
USE_BLE_CLIENT_LEGACY_ENGINE in _to_code_esp32)."""
|
||||
return CORE.is_esp32
|
||||
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
"""The engine's closure per platform: the legacy esp32 engine builds on
|
||||
esp32_ble_client plus bluetooth_connection (the shared service-table
|
||||
materializer; its sources compile empty in builds without a neutral
|
||||
node), the neutral engine on the bluetooth_connection backend. The
|
||||
platform-less arm is the union for manifest-resolving tooling."""
|
||||
if _legacy_engine() or CORE.target_platform is None:
|
||||
return ["bluetooth_connection", "esp32_ble_client"]
|
||||
return ["bluetooth_connection"]
|
||||
|
||||
|
||||
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
DOMAIN = "ble_client"
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
{
|
||||
"ble_client.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
# Every framework of every non-esp32 registry platform: a platform
|
||||
# that validates the neutral arm must also compile the neutral engine.
|
||||
"ble_client_gatt.cpp": frameworks_for_platforms(
|
||||
set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
@@ -59,7 +114,9 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
ble_client_ns = cg.esphome_ns.namespace("ble_client")
|
||||
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
|
||||
# One codegen class for both engines: the exclusively-gated headers resolve
|
||||
# the name to exactly one C++ definition per build.
|
||||
BLEClient = ble_client_ns.class_("BLEClient", cg.Component)
|
||||
BLEClientNode = ble_client_ns.class_("BLEClientNode")
|
||||
BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const")
|
||||
# Triggers
|
||||
@@ -106,62 +163,179 @@ CONF_AUTO_CONNECT = "auto_connect"
|
||||
|
||||
MULTI_CONF = True
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEClient),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientConnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientDisconnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyNotificationTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientNumericComparisonRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
# Keys shared by both engines' schemas.
|
||||
_COMMON_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BLEClient),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BLEClientConnectTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientDisconnectTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _esp32_config_schema() -> cv.All:
|
||||
"""The legacy engine's schema, byte-compatible with what esp32 always had
|
||||
(including the Bluedroid security triggers)."""
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
return cv.All(
|
||||
_COMMON_SCHEMA.extend(
|
||||
{
|
||||
# Accepted-but-unused legacy key; not propagated to the
|
||||
# neutral schema.
|
||||
cv.Optional(CONF_NAME): cv.string,
|
||||
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_PASSKEY_NOTIFICATION
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientPasskeyNotificationTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_NUMERIC_COMPARISON_REQUEST
|
||||
): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
BLEClientNumericComparisonRequestTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
bluetooth_connection.consume_gatt_slot("ble_client"),
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
|
||||
esp32_ble.consume_connection_slots(1, "ble_client"),
|
||||
)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _gatt_config_schema(platform: str) -> cv.All:
|
||||
"""The neutral engine's schema: the shared keys plus the hub reference
|
||||
(parsed-advertisement sightings) and the GATT backend declaration.
|
||||
Keyed by platform - the backend fragment differs per platform."""
|
||||
return cv.All(
|
||||
_COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend(
|
||||
bluetooth_connection.gatt_client_schema(platform)
|
||||
),
|
||||
bluetooth_connection.consume_gatt_slot("ble_client"),
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# Deliberate gap (the bluetooth_proxy pattern): the dumper gets only
|
||||
# this shape, so the neutral arm's ble_hub_id is absent from editor
|
||||
# schemas and the esp32-only keys are advertised on every platform.
|
||||
# The language-schema dumper runs without a platform; expose the
|
||||
# esp32 (legacy-engine) shape.
|
||||
return _esp32_config_schema()
|
||||
if _legacy_engine():
|
||||
return _esp32_config_schema()(config)
|
||||
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
|
||||
return _gatt_config_schema(CORE.target_platform)(config)
|
||||
raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}")
|
||||
|
||||
|
||||
CONFIG_SCHEMA = _validate_platform
|
||||
|
||||
CONF_BLE_CLIENT_ID = "ble_client_id"
|
||||
|
||||
|
||||
class BLEClientFeatures(StrEnum):
|
||||
"""Per-platform engine capabilities consumers declare against."""
|
||||
|
||||
# The platform-neutral node interface (on_connected/table + completion
|
||||
# callbacks) - every platform with a ble_client engine.
|
||||
GATT_NODE = "gatt_node"
|
||||
# The raw esp32 GATT client event stream (gattc/gap handlers,
|
||||
# node_state) - the legacy engine only.
|
||||
RAW_GATTC = "raw_gattc"
|
||||
# Pairing dialog replies and bond management (Bluedroid GAP/SMP).
|
||||
SECURITY = "security"
|
||||
|
||||
|
||||
def _engine_features() -> set[BLEClientFeatures]:
|
||||
"""Features the validated platform's engine provides."""
|
||||
if _legacy_engine():
|
||||
return {
|
||||
BLEClientFeatures.GATT_NODE,
|
||||
BLEClientFeatures.RAW_GATTC,
|
||||
BLEClientFeatures.SECURITY,
|
||||
}
|
||||
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
|
||||
return {BLEClientFeatures.GATT_NODE}
|
||||
return set()
|
||||
|
||||
|
||||
def requires_feature(
|
||||
feature: BLEClientFeatures, description: str
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Validator gating a consumer to platforms whose engine provides
|
||||
`feature`, naming the missing capability in the error."""
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
features = _engine_features()
|
||||
if feature not in features:
|
||||
available = (
|
||||
f"; this platform's engine provides: {', '.join(sorted(features))}"
|
||||
if features
|
||||
else ""
|
||||
)
|
||||
raise cv.Invalid(
|
||||
f"{description} requires the ble_client '{feature}' feature, "
|
||||
f"which {CORE.target_platform} does not provide{available}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# The one choke point for every node component still on the raw esp32 event
|
||||
# stream; migrating to the neutral interface (NODE_BLE_CLIENT_SCHEMA +
|
||||
# register_gatt_node) lifts it.
|
||||
_legacy_engine_only = requires_feature(
|
||||
BLEClientFeatures.RAW_GATTC,
|
||||
"This component drives the raw ESP32 GATT client events and has not "
|
||||
"been migrated to the platform-neutral node interface yet; it",
|
||||
)
|
||||
|
||||
BLE_CLIENT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient),
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
|
||||
cv.use_id(BLEClient), _legacy_engine_only
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# For node components on the neutral interface: valid wherever ble_client
|
||||
# itself is.
|
||||
NODE_BLE_CLIENT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
|
||||
cv.use_id(BLEClient),
|
||||
requires_feature(BLEClientFeatures.GATT_NODE, "This component"),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -171,11 +345,31 @@ async def register_ble_node(var, config):
|
||||
cg.add(parent.register_ble_node(var))
|
||||
|
||||
|
||||
def _request_gatt_node_build() -> None:
|
||||
"""Node storage and the one define meaning "the neutral node surface is
|
||||
compiled in", plus the esp32 bridge/materializer defines."""
|
||||
_request_node_slot()
|
||||
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
|
||||
if _legacy_engine():
|
||||
# Deliberately not ble_device_base.request_gatt_client(): that would
|
||||
# claim a phantom backend slot on combined proxy builds.
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
cg.add_define("USE_BLE_GATT_BACKEND_BLUEDROID")
|
||||
cg.add_define("USE_BLUEDROID_GATT_SERVICE_TABLE")
|
||||
|
||||
|
||||
async def register_gatt_node(var, config):
|
||||
"""Register a node on the platform-neutral interface (both engines)."""
|
||||
parent = await cg.get_variable(config[CONF_BLE_CLIENT_ID])
|
||||
_request_gatt_node_build()
|
||||
cg.add(parent.register_gatt_node(var))
|
||||
|
||||
|
||||
BLE_WRITE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
|
||||
cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid,
|
||||
cv.Required(CONF_SERVICE_UUID): bt_uuid,
|
||||
cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid,
|
||||
cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
|
||||
}
|
||||
)
|
||||
@@ -186,25 +380,34 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id(
|
||||
}
|
||||
)
|
||||
|
||||
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
|
||||
}
|
||||
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
|
||||
}
|
||||
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
}
|
||||
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All(
|
||||
requires_feature(BLEClientFeatures.SECURITY, "This action"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -232,6 +435,8 @@ automation.register_simple_action(
|
||||
)
|
||||
async def ble_write_to_code(config, action_id, template_arg, args):
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
# The action registers itself as a neutral node in its constructor.
|
||||
_request_gatt_node_build()
|
||||
var = cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
value = config[CONF_VALUE]
|
||||
@@ -246,38 +451,20 @@ async def ble_write_to_code(config, action_id, template_arg, args):
|
||||
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*value))
|
||||
cg.add(var.set_value_simple(arr, len(value)))
|
||||
|
||||
if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
|
||||
cg.add(
|
||||
var.set_service_uuid16(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
|
||||
)
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid32_format):
|
||||
cg.add(
|
||||
var.set_service_uuid32(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
|
||||
)
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid128_format):
|
||||
uuid128 = esp32_ble_tracker.as_reversed_hex_array(config[CONF_SERVICE_UUID])
|
||||
if len(config[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(var.set_service_uuid16(as_hex(config[CONF_SERVICE_UUID])))
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid32_format):
|
||||
cg.add(var.set_service_uuid32(as_hex(config[CONF_SERVICE_UUID])))
|
||||
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid128_format):
|
||||
uuid128 = as_reversed_hex_array(config[CONF_SERVICE_UUID])
|
||||
cg.add(var.set_service_uuid128(uuid128))
|
||||
|
||||
if len(config[CONF_CHARACTERISTIC_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
|
||||
cg.add(
|
||||
var.set_char_uuid16(
|
||||
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
|
||||
)
|
||||
)
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
|
||||
esp32_ble_tracker.bt_uuid32_format
|
||||
):
|
||||
cg.add(
|
||||
var.set_char_uuid32(
|
||||
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
|
||||
)
|
||||
)
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
|
||||
esp32_ble_tracker.bt_uuid128_format
|
||||
):
|
||||
uuid128 = esp32_ble_tracker.as_reversed_hex_array(
|
||||
config[CONF_CHARACTERISTIC_UUID]
|
||||
)
|
||||
if len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid16_format):
|
||||
cg.add(var.set_char_uuid16(as_hex(config[CONF_CHARACTERISTIC_UUID])))
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid32_format):
|
||||
cg.add(var.set_char_uuid32(as_hex(config[CONF_CHARACTERISTIC_UUID])))
|
||||
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid128_format):
|
||||
uuid128 = as_reversed_hex_array(config[CONF_CHARACTERISTIC_UUID])
|
||||
cg.add(var.set_char_uuid128(uuid128))
|
||||
|
||||
return var
|
||||
@@ -331,14 +518,45 @@ automation.register_simple_action(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def _to_code_esp32(config: ConfigType) -> cg.MockObj:
|
||||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
|
||||
# Register the loggers this component needs
|
||||
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
|
||||
cg.add_define("USE_ESP32_BLE_UUID")
|
||||
cg.add_define("USE_BLE_CLIENT_LEGACY_ENGINE")
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await esp32_ble_tracker.register_client(var, config)
|
||||
return var
|
||||
|
||||
|
||||
# Sizes the neutral client's node storage; the client itself requests a
|
||||
# baseline slot so the define exists on every build that compiles the engine.
|
||||
_request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
|
||||
|
||||
|
||||
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
|
||||
# The engine always carries the node surface (the client itself owns the
|
||||
# baseline slot).
|
||||
_request_gatt_node_build()
|
||||
backend = await bluetooth_connection.new_gatt_backend(config)
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_backend(backend))
|
||||
# Sighting-gated connects: the client listens for the peer's parsed
|
||||
# advertisements through the hub.
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if _legacy_engine():
|
||||
var = await _to_code_esp32(config)
|
||||
else:
|
||||
var = await _to_code_gatt(config)
|
||||
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
|
||||
cg.add(var.set_auto_connect(config[CONF_AUTO_CONNECT]))
|
||||
for conf in config.get(CONF_ON_CONNECT, []):
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
const char *const Automation::TAG = "ble_client.automation";
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
@@ -1,27 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
|
||||
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
// placeholder class for static TAG .
|
||||
class Automation {
|
||||
public:
|
||||
// could be made inline with C++17
|
||||
static const char *const TAG;
|
||||
};
|
||||
|
||||
// Base for nodes that never read the parent's services.
|
||||
// The parent releases its services only once every node reports Established, so a node that never
|
||||
// reports it keeps that memory allocated for the life of the connection.
|
||||
@@ -110,144 +97,6 @@ class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>,
|
||||
}
|
||||
};
|
||||
|
||||
// implement the ble_client.ble_write action.
|
||||
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientWriteAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
|
||||
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->value_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
// Store pointer to static data in flash (no RAM copy)
|
||||
void set_value_simple(const uint8_t *data, size_t len) {
|
||||
this->value_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->var_ = std::make_tuple(x...);
|
||||
|
||||
bool result;
|
||||
if (this->len_ >= 0) {
|
||||
// Static mode: write directly from flash pointer
|
||||
result = this->write(this->value_.data, this->len_);
|
||||
} else {
|
||||
// Template mode: call function and write the vector
|
||||
std::vector<uint8_t> value = this->value_.func(x...);
|
||||
result = this->write(value);
|
||||
}
|
||||
|
||||
// on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work.
|
||||
if (!result)
|
||||
this->play_next_(x...);
|
||||
}
|
||||
|
||||
/**
|
||||
* Note about logging: the esph_log_X macros are used here because the CI checks complain about use of the ESP LOG
|
||||
* macros in header files (Can't even write it in a comment!)
|
||||
* Not sure why, because they seem to work just fine.
|
||||
* The problem is that the implementation of a templated class can't be placed in a .cpp file when using C++ less than
|
||||
* 17, so the methods have to be here. The esph_log_X macros are equivalent in function, but don't trigger the CI
|
||||
* errors.
|
||||
*/
|
||||
// initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event.
|
||||
bool write(const uint8_t *data, size_t len) {
|
||||
if (this->node_state != espbt::ClientState::ESTABLISHED) {
|
||||
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
|
||||
return false;
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
|
||||
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
|
||||
#endif
|
||||
esp_err_t err =
|
||||
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len,
|
||||
const_cast<uint8_t *>(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (err != ESP_OK) {
|
||||
esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write(const std::vector<uint8_t> &value) { return this->write(value.data(), value.size()); }
|
||||
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
switch (event) {
|
||||
case ESP_GATTC_WRITE_CHAR_EVT:
|
||||
// upstream code checked the MAC address, verify the characteristic.
|
||||
if (param->write.handle == this->char_handle_)
|
||||
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
break;
|
||||
case ESP_GATTC_DISCONNECT_EVT:
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
break;
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
auto *chr = this->parent()->get_characteristic(this->service_uuid_, this->char_uuid_);
|
||||
if (chr == nullptr) {
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
char service_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s",
|
||||
this->char_uuid_.to_str(char_buf), this->service_uuid_.to_str(service_buf));
|
||||
break;
|
||||
}
|
||||
this->char_handle_ = chr->handle;
|
||||
this->char_props_ = chr->properties;
|
||||
if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE) {
|
||||
this->write_type_ = ESP_GATT_WRITE_TYPE_RSP;
|
||||
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_RSP");
|
||||
} else if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) {
|
||||
this->write_type_ = ESP_GATT_WRITE_TYPE_NO_RSP;
|
||||
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_NO_RSP");
|
||||
} else {
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
|
||||
break;
|
||||
}
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
char char_buf[esp32_ble::UUID_STR_LEN];
|
||||
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
|
||||
ble_client_->address_str());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Value {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} value_;
|
||||
espbt::ESPBTUUID service_uuid_;
|
||||
espbt::ESPBTUUID char_uuid_;
|
||||
std::tuple<Ts...> var_{};
|
||||
uint16_t char_handle_{};
|
||||
esp_gatt_char_prop_t char_props_{};
|
||||
esp_gatt_write_type_t write_type_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientPasskeyReplyAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; }
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Neutral twins of the shared ble_client automations. Class names, namespace,
|
||||
// and codegen-visible signatures are IDENTICAL to automation.h so generated
|
||||
// main.cpp compiles against whichever engine the build gates in; only the
|
||||
// internals differ (client callbacks and the neutral node interface instead
|
||||
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
|
||||
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include <tuple>
|
||||
|
||||
#include "ble_client_gatt.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClientConnectTrigger final : public Trigger<> {
|
||||
public:
|
||||
explicit BLEClientConnectTrigger(BLEClient *parent) {
|
||||
parent->add_on_connect_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientDisconnectTrigger final : public Trigger<> {
|
||||
public:
|
||||
explicit BLEClientDisconnectTrigger(BLEClient *parent) {
|
||||
// Fires only after a completed connection (never for failed attempts),
|
||||
// matching the legacy CLOSE_EVT semantics.
|
||||
parent->add_on_disconnect_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientConnectAction(BLEClient *ble_client) {
|
||||
ble_client_ = ble_client;
|
||||
ble_client->add_on_connect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
// A connect attempt that dies (or a later disconnect) terminates the
|
||||
// chain, mirroring the legacy DISCONNECT_EVT handling.
|
||||
ble_client->add_on_connect_failed_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
});
|
||||
ble_client->add_on_disconnect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
});
|
||||
}
|
||||
|
||||
// not used since we override play_complex_
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
// it makes no sense to have multiple instances of this running at the
|
||||
// same time; cancel a re-trigger while still running.
|
||||
if (this->num_running_ != 0) {
|
||||
this->stop_complex();
|
||||
return;
|
||||
}
|
||||
this->num_running_++;
|
||||
if (this->ble_client_->connected()) {
|
||||
this->play_next_(x...);
|
||||
} else {
|
||||
this->var_ = std::make_tuple(x...);
|
||||
// No-op while already connecting; the callback resolves the wait.
|
||||
this->ble_client_->connect();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
|
||||
public:
|
||||
BLEClientDisconnectAction(BLEClient *ble_client) {
|
||||
ble_client_ = ble_client;
|
||||
// Both terminal outcomes resolve the wait: a completed teardown and a
|
||||
// connect attempt that died on the way down.
|
||||
ble_client->add_on_disconnect_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
ble_client->add_on_connect_failed_callback([this]() {
|
||||
if (this->num_running_ != 0)
|
||||
this->play_next_tuple_(this->var_);
|
||||
});
|
||||
}
|
||||
|
||||
// not used since we override play_complex_
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
if (this->ble_client_->idle()) {
|
||||
this->play_next_(x...);
|
||||
} else {
|
||||
this->var_ = std::make_tuple(x...);
|
||||
this->ble_client_->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -2,10 +2,16 @@
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
@@ -30,6 +36,10 @@ void BLEClient::dump_config() {
|
||||
bool BLEClient::parse_device(const espbt::ESPBTDevice &device) {
|
||||
if (!this->enabled)
|
||||
return false;
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
if (device.address_uint64() == this->address_ && this->gatt_backoff_.holding_off())
|
||||
return false;
|
||||
#endif
|
||||
return BLEClientBase::parse_device(device);
|
||||
}
|
||||
|
||||
@@ -40,24 +50,60 @@ void BLEClient::set_enabled(bool enabled) {
|
||||
if (!enabled) {
|
||||
ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str());
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// A re-enable clears the backoff (neutral-engine parity).
|
||||
this->gatt_backoff_.reset();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Bridge-initiated registrations bypass the base's REG_FOR_NOTIFY handling:
|
||||
// its automatic CCCD write would double the node's own.
|
||||
// Handle-keyed: mixed legacy/neutral subscriptions to one characteristic
|
||||
// are unsupported during the migration window.
|
||||
if (event == ESP_GATTC_REG_FOR_NOTIFY_EVT && esp_gattc_if == this->gattc_if_ &&
|
||||
this->take_pending_gatt_reg_(param->reg_for_notify.handle)) {
|
||||
if (this->pending_notify_regs_ > 0)
|
||||
this->pending_notify_regs_--;
|
||||
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
|
||||
this->notify_state_to_gatt_nodes_(param->reg_for_notify.handle, true, err);
|
||||
// A retiring last registration must still release the cache.
|
||||
this->maybe_release_services_();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
|
||||
return false;
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Before the legacy fan-out so gatt nodes resolve before any trigger fires.
|
||||
if (!this->gatt_nodes_.empty()) {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
|
||||
// A failed discovery tears the link down; the on_connect trigger must
|
||||
// not fire into the teardown.
|
||||
if (!this->handle_gatt_search_cmpl_(param->search_cmpl.status))
|
||||
return true;
|
||||
} else {
|
||||
this->dispatch_gatt_event_(event, param);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
for (auto *node : this->nodes_)
|
||||
node->gattc_event_handler(event, esp_gattc_if, param);
|
||||
this->maybe_release_services_();
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::maybe_release_services_() {
|
||||
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
|
||||
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
|
||||
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
|
||||
this->release_services();
|
||||
ESP_LOGD(TAG, "All clients established, services released");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
@@ -65,10 +111,19 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
|
||||
|
||||
for (auto *node : this->nodes_)
|
||||
node->gap_event_handler(event, param);
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
if (event == ESP_GAP_BLE_AUTH_CMPL_EVT && this->check_addr(param->ble_security.auth_cmpl.bd_addr)) {
|
||||
int status = param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason;
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_pairing_result(status);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void BLEClient::set_state(espbt::ClientState state) {
|
||||
BLEClientBase::set_state(state);
|
||||
// ESTABLISHED never flows through here; gatt nodes are promoted after the
|
||||
// on_connected fan-out.
|
||||
for (auto &node : nodes_)
|
||||
node->node_state = state;
|
||||
}
|
||||
@@ -83,6 +138,221 @@ bool BLEClient::all_nodes_established_() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
void BLEClient::register_gatt_node(BLEClientNode *node) {
|
||||
// Parent before the capacity check so a dropped node still has a usable
|
||||
// parent() (neutral-engine parity).
|
||||
node->set_ble_client_parent(this);
|
||||
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
|
||||
// push_back past capacity is a silent no-op; an undersized slot count
|
||||
// must be loud at boot, not an unresolvable node at runtime.
|
||||
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str());
|
||||
this->status_set_error(LOG_STR("node capacity exceeded"));
|
||||
return;
|
||||
}
|
||||
this->gatt_nodes_.push_back(node);
|
||||
// nodes_ covers the shared state bookkeeping; gatt_nodes_ is the neutral
|
||||
// fan-out subset.
|
||||
this->register_ble_node(node);
|
||||
}
|
||||
|
||||
int BLEClient::find_pending_gatt_reg_(uint16_t handle) const {
|
||||
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
|
||||
if (this->pending_gatt_regs_[i] == handle)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
|
||||
int i = this->find_pending_gatt_reg_(handle);
|
||||
if (i < 0)
|
||||
return false;
|
||||
// No duplicates (notify_characteristic refuses a re-push); swap-with-last.
|
||||
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str(),
|
||||
enabled ? "enable" : "disable", handle, error);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_notify_state(handle, enabled, error);
|
||||
}
|
||||
|
||||
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_READ_CHAR_EVT:
|
||||
case ESP_GATTC_READ_DESCR_EVT: {
|
||||
bool ok = param->read.status == ESP_GATT_OK;
|
||||
if (!ok) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str(), param->read.handle,
|
||||
param->read.status);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
|
||||
ok ? 0 : param->read.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_WRITE_CHAR_EVT:
|
||||
case ESP_GATTC_WRITE_DESCR_EVT:
|
||||
if (param->write.status != ESP_GATT_OK) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str(), param->write.handle,
|
||||
param->write.status);
|
||||
}
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
|
||||
}
|
||||
break;
|
||||
case ESP_GATTC_NOTIFY_EVT:
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_notify(param->notify.handle, param->notify.value, param->notify.value_len);
|
||||
}
|
||||
break;
|
||||
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT:
|
||||
// The base does no CCCD work for unregister; no interception needed.
|
||||
this->notify_state_to_gatt_nodes_(
|
||||
param->unreg_for_notify.handle, false,
|
||||
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
|
||||
// The base ignores the search status; the neutral contract must not.
|
||||
uint16_t service_total = 0;
|
||||
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
|
||||
this->gattc_if_, this->conn_id_, &service_total);
|
||||
if (!counted || service_total == 0) {
|
||||
// A failed search poisons the whole discovery, legacy nodes included.
|
||||
ESP_LOGW(TAG, "[%s] Discovery failed (status=%d, services=%u)", this->address_str(), status, service_total);
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
this->disconnect();
|
||||
return false;
|
||||
}
|
||||
// Stack-owned; nodes copy their handles during on_connected().
|
||||
bluetooth_connection::BluedroidServiceTable table;
|
||||
if (!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
|
||||
if (!this->has_legacy_nodes_()) {
|
||||
ESP_LOGW(TAG, "[%s] Service table build failed; treating as failed discovery", this->address_str());
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
this->disconnect();
|
||||
return false;
|
||||
}
|
||||
// Only the table build failed; legacy nodes read the base's services_
|
||||
// and keep the link. Gatt nodes catch the next connection.
|
||||
ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str());
|
||||
this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed"));
|
||||
// A peer whose database never walks must not retry the build at
|
||||
// advertisement cadence once the legacy nodes drop the link.
|
||||
this->gatt_backoff_.register_failure(this->address_str());
|
||||
} else {
|
||||
this->gatt_connected_ = true;
|
||||
auto view = table.view();
|
||||
for (auto *node : this->gatt_nodes_) {
|
||||
node->on_connected(view);
|
||||
if (this->state() != espbt::ClientState::ESTABLISHED) {
|
||||
// The node tore the link down; remaining nodes get on_disconnected
|
||||
// with no preceding on_connected, so leave a trace of why.
|
||||
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this->gatt_backoff_.reset();
|
||||
this->status_clear_warning();
|
||||
}
|
||||
// Promote so the legacy release condition can fire.
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->node_state = espbt::ClientState::ESTABLISHED;
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::on_disconnect_complete(esp_err_t reason) {
|
||||
this->pending_gatt_reg_count_ = 0;
|
||||
if (!this->gatt_connected_)
|
||||
return; // Never-established links report nothing (neutral parity).
|
||||
this->gatt_connected_ = false;
|
||||
for (auto *node : this->gatt_nodes_)
|
||||
node->on_disconnected();
|
||||
}
|
||||
|
||||
int BLEClient::check_and_log_error_(const char *operation, esp_err_t err) {
|
||||
if (err != ESP_OK)
|
||||
this->log_gattc_warning_(operation, err);
|
||||
return err;
|
||||
}
|
||||
|
||||
int BLEClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char",
|
||||
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
|
||||
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
|
||||
ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::read_characteristic(uint16_t handle) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
|
||||
handle, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::read_descriptor(uint16_t handle) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_read_char_descr",
|
||||
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
return this->check_and_log_error_(
|
||||
"esp_ble_gattc_write_char_descr",
|
||||
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
|
||||
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
|
||||
}
|
||||
|
||||
int BLEClient::notify_characteristic(uint16_t handle, bool enable) {
|
||||
if (this->conn_id_ == UNSET_CONN_ID)
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
if (enable) {
|
||||
if (this->find_pending_gatt_reg_(handle) >= 0) {
|
||||
// ESP_OK: the in-flight registration's completion fans out to all nodes.
|
||||
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (this->pending_gatt_reg_count_ == MAX_PENDING_NOTIFY_REGS) {
|
||||
// An untracked registration would let the base's auto-CCCD through.
|
||||
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
// The base helper's pending count holds the service-release until the
|
||||
// (intercepted) completion.
|
||||
esp_err_t err = this->register_for_notify(handle);
|
||||
if (err == ESP_OK)
|
||||
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
|
||||
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
|
||||
}
|
||||
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
|
||||
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
|
||||
}
|
||||
|
||||
int BLEClient::unpair() { return bluetooth_connection::unpair_device(this->get_address()); }
|
||||
|
||||
#endif // USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
|
||||
#include "ble_client_node.h"
|
||||
#include "connect_backoff.h"
|
||||
#include "esphome/components/esp32_ble_client/ble_client_base.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <esp_bt_defs.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gatt_common_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
@@ -21,34 +23,6 @@ namespace espbt = esphome::esp32_ble_tracker;
|
||||
|
||||
using namespace esp32_ble_client;
|
||||
|
||||
class BLEClient;
|
||||
|
||||
class BLEClientNode {
|
||||
public:
|
||||
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param){};
|
||||
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
|
||||
virtual void loop() {}
|
||||
void set_address(uint64_t address) { address_ = address; }
|
||||
espbt::ESPBTClient *client;
|
||||
// This should be transitioned to Established once the node no longer needs
|
||||
// the services/descriptors/characteristics of the parent client. This will
|
||||
// allow some memory to be freed.
|
||||
// The parent frees the peer's GATT cache once every node reports Established.
|
||||
// Never report Established while an operation that reads that cache is outstanding.
|
||||
// - esp_ble_gattc_register_for_notify() completes asynchronously.
|
||||
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
|
||||
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
|
||||
espbt::ClientState node_state;
|
||||
|
||||
BLEClient *parent() { return this->parent_; }
|
||||
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
|
||||
|
||||
protected:
|
||||
BLEClient *parent_;
|
||||
uint64_t address_;
|
||||
};
|
||||
|
||||
class BLEClient final : public BLEClientBase {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -64,7 +38,6 @@ class BLEClient final : public BLEClientBase {
|
||||
void set_enabled(bool enabled);
|
||||
|
||||
void register_ble_node(BLEClientNode *node) {
|
||||
node->client = this;
|
||||
node->set_ble_client_parent(this);
|
||||
this->nodes_.push_back(node);
|
||||
}
|
||||
@@ -73,10 +46,57 @@ class BLEClient final : public BLEClientBase {
|
||||
|
||||
void set_state(espbt::ClientState state) override;
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// ---- the neutral node surface (signatures shared with the non-esp32
|
||||
// engine, so nodes on the neutral interface compile against either) ----
|
||||
void register_gatt_node(BLEClientNode *node);
|
||||
|
||||
bool idle() const { return this->state() == espbt::ClientState::IDLE; }
|
||||
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
|
||||
int read_characteristic(uint16_t handle);
|
||||
int read_descriptor(uint16_t handle);
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
|
||||
/// Local registration only; per the neutral contract the CCCD write is the
|
||||
/// node's job (the legacy auto-CCCD is suppressed for these handles).
|
||||
int notify_characteristic(uint16_t handle, bool enable);
|
||||
// pair() comes from BLEClientBase, matching the neutral engine's.
|
||||
int unpair();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool all_nodes_established_();
|
||||
void maybe_release_services_();
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
int check_and_log_error_(const char *operation, esp_err_t err);
|
||||
int find_pending_gatt_reg_(uint16_t handle) const;
|
||||
void notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error);
|
||||
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
|
||||
// False = failed discovery: the link comes down and the caller suppresses
|
||||
// the legacy fan-out.
|
||||
bool handle_gatt_search_cmpl_(esp_gatt_status_t status);
|
||||
bool take_pending_gatt_reg_(uint16_t handle);
|
||||
void on_disconnect_complete(esp_err_t reason) override;
|
||||
#endif
|
||||
|
||||
std::vector<BLEClientNode *> nodes_;
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Raise if a migrated node needs more concurrent registrations.
|
||||
static constexpr uint8_t MAX_PENDING_NOTIFY_REGS = 4;
|
||||
|
||||
// Nodes on the neutral surface; fed the translated callbacks and
|
||||
// auto-established after the on_connected fan-out. Every gatt node is
|
||||
// also in nodes_ (registration pushes into both).
|
||||
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
|
||||
bool has_legacy_nodes_() const { return this->nodes_.size() > this->gatt_nodes_.size(); }
|
||||
// Reconnect backoff after materializer failures.
|
||||
ConnectBackoff gatt_backoff_;
|
||||
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
|
||||
uint16_t pending_gatt_regs_[MAX_PENDING_NOTIFY_REGS];
|
||||
uint8_t pending_gatt_reg_count_{0};
|
||||
// on_connected fan-out started; on_disconnected is owed at teardown.
|
||||
bool gatt_connected_{false};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#include "ble_client_gatt.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_client");
|
||||
|
||||
void BLEClient::register_ble_node(BLEClientNode *node) {
|
||||
node->set_ble_client_parent(this);
|
||||
if (this->nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
|
||||
// push_back past capacity is a silent no-op; an undersized slot count
|
||||
// must be loud at boot, not an unresolvable node at runtime.
|
||||
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str_);
|
||||
this->status_set_error(LOG_STR("node capacity exceeded"));
|
||||
return;
|
||||
}
|
||||
this->nodes_.push_back(node);
|
||||
}
|
||||
|
||||
void BLEClient::set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
uint8_t mac[6];
|
||||
ble_device_base::uint64_to_mac_msb_first(address, mac);
|
||||
format_mac_addr_upper(mac, this->address_str_);
|
||||
}
|
||||
|
||||
void BLEClient::set_enabled(bool enabled) {
|
||||
if (enabled == this->enabled)
|
||||
return;
|
||||
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
|
||||
this->enabled = enabled;
|
||||
if (!enabled) {
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
// A re-enable clears the backoff; the next sighting connects (legacy
|
||||
// parity: enabling does not itself connect).
|
||||
this->backoff_.reset();
|
||||
}
|
||||
|
||||
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != this->address_)
|
||||
return false;
|
||||
// The sighting is the source of truth for the address type.
|
||||
this->address_type_ = device.get_address_type();
|
||||
this->address_type_known_ = true;
|
||||
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
|
||||
return true;
|
||||
if (this->backoff_.holding_off())
|
||||
return true;
|
||||
this->attempt_connect_();
|
||||
return true;
|
||||
}
|
||||
|
||||
void BLEClient::connect() {
|
||||
if (this->state_ != State::IDLE) {
|
||||
ESP_LOGD(TAG, "[%s] Connect requested while busy, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
// An absent peer can inhibit scanning for the backend's full connect
|
||||
// timeout, so this is worth a breadcrumb - but it is a supported action.
|
||||
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
|
||||
if (!this->address_type_known_) {
|
||||
// Legacy parity: without a sighting the address type defaults to
|
||||
// public, which never matches a random-static peer.
|
||||
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
|
||||
}
|
||||
this->attempt_connect_();
|
||||
}
|
||||
|
||||
void BLEClient::attempt_connect_() {
|
||||
int err = this->backend_->connect(this->address_, this->address_type_);
|
||||
if (err != 0) {
|
||||
// A refused connect never produces a callback: stay idle, charge the
|
||||
// backoff, and resolve any waiting connect action through the failure
|
||||
// path so its chain terminates.
|
||||
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
this->defer([this]() { this->connect_failed_callbacks_.call(); });
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
|
||||
this->state_ = State::CONNECTING;
|
||||
}
|
||||
|
||||
void BLEClient::disconnect() {
|
||||
if (this->state_ == State::IDLE) {
|
||||
ESP_LOGD(TAG, "[%s] Disconnect requested while idle, ignoring", this->address_str_);
|
||||
return;
|
||||
}
|
||||
// A deliberate teardown's failure report must not feed the backoff.
|
||||
this->cancel_requested_ = true;
|
||||
int err = this->backend_->gatt_disconnect();
|
||||
if (err != 0) {
|
||||
// Refused synchronously: backend and client disagree about the link
|
||||
// state. Warn, then settle through the deliberate-cancel path.
|
||||
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
|
||||
this->on_connection_state(false, 0, err);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
|
||||
if (connected) {
|
||||
this->state_ = State::DISCOVERING;
|
||||
int discover_err = this->backend_->discover_services();
|
||||
if (discover_err != 0) {
|
||||
// Synchronous refusal: no discovery completion will follow.
|
||||
ESP_LOGW(TAG, "[%s] Service discovery refused, err=%d", this->address_str_, discover_err);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
// Deliberate teardown: its report must not charge the backoff again.
|
||||
this->disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
bool was_connected = this->state_ == State::CONNECTED;
|
||||
bool cancelled = this->cancel_requested_;
|
||||
this->cancel_requested_ = false;
|
||||
this->state_ = State::IDLE;
|
||||
if (was_connected) {
|
||||
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_disconnected();
|
||||
}
|
||||
// Continuations leave the backend's event-drain stack first.
|
||||
this->defer([this]() { this->disconnect_callbacks_.call(); });
|
||||
} else {
|
||||
if (cancelled) {
|
||||
// status carries the refusal code when the teardown settled
|
||||
// synchronously; 0 on a backend-completed cancel.
|
||||
ESP_LOGD(TAG, "[%s] Connect attempt cancelled, status=%d", this->address_str_, error);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
}
|
||||
this->defer([this]() { this->connect_failed_callbacks_.call(); });
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_service_discovery_done(int error) {
|
||||
if (this->state_ != State::DISCOVERING || this->cancel_requested_) {
|
||||
// Raced a teardown; the terminal report settles the link.
|
||||
ESP_LOGD(TAG, "[%s] Discovery completed during teardown, ignoring", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
// The teardown is deliberate: do not charge the backoff again for its
|
||||
// connection report.
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
ble_device_base::GattServiceTable table{};
|
||||
if (!this->nodes_.empty()) {
|
||||
// Materialize only when a node will read it: a client with no nodes
|
||||
// would pay the build/free cycle on every (re)connect for nothing.
|
||||
table = this->backend_->get_service_table();
|
||||
if (table.service_count == 0) {
|
||||
// A failed materialization is indistinguishable from a service-less
|
||||
// peer, and a real GATT peer always exposes at least GAP/GATT: fail
|
||||
// the discovery before CONNECTED so the teardown resolves through
|
||||
// connect_failed, never a spurious on_disconnect.
|
||||
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
this->backoff_.register_failure(this->address_str_);
|
||||
this->disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// CONNECTED before the fan-out so nodes may consult connected() from
|
||||
// their own on_connected().
|
||||
this->state_ = State::CONNECTED;
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_connected(table);
|
||||
if (this->state_ != State::CONNECTED || this->cancel_requested_) {
|
||||
// A node tore the link down mid-fan-out: on_disconnect fires with no
|
||||
// preceding on_connect, so leave a trace of why.
|
||||
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str_);
|
||||
this->backend_->release_services();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->backend_->release_services();
|
||||
this->backoff_.reset();
|
||||
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
|
||||
this->defer([this]() { this->connect_callbacks_.call(); });
|
||||
}
|
||||
|
||||
void BLEClient::on_write_result(uint16_t handle, int error) {
|
||||
if (error != 0) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str_, handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_write_result(handle, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
|
||||
if (error != 0) {
|
||||
// Breadcrumb even when no node claims the handle.
|
||||
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str_, handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_read_result(handle, data, len, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
// Every node sees every notification and filters by handle (legacy parity).
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_notify(handle, data, len);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_notify_state(uint16_t handle, bool enabled, int error) {
|
||||
if (error != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str_,
|
||||
enabled ? "enable" : "disable", handle, error);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_notify_state(handle, enabled, error);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::on_pairing_result(int status) {
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%s] Pairing failed, status=%d", this->address_str_, status);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "[%s] Paired", this->address_str_);
|
||||
}
|
||||
for (auto *node : this->nodes_) {
|
||||
node->on_pairing_result(status);
|
||||
}
|
||||
}
|
||||
|
||||
void BLEClient::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BLE Client:\n"
|
||||
" Address: %s\n"
|
||||
" Auto connect: %s",
|
||||
this->address_str_, YESNO(this->auto_connect_));
|
||||
if (this->enabled && this->state_ == State::IDLE) {
|
||||
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -0,0 +1,149 @@
|
||||
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
|
||||
//
|
||||
// Compiled on every platform with a GATT backend except esp32, which keeps
|
||||
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
|
||||
// family migrates - the exclusive gates make the same class names resolve to
|
||||
// exactly one definition per build, so codegen is shared.
|
||||
//
|
||||
// Connects are sighting-gated like the legacy engine: the client is a parsed
|
||||
// advertisement listener, captures the peer's address type from the sighting,
|
||||
// and asks the backend to connect only when enabled and idle.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
|
||||
|
||||
#include "ble_client_node.h"
|
||||
#include "connect_backoff.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClient : public Component,
|
||||
public ble_device_base::ESPBTDeviceListener,
|
||||
public ble_device_base::GattClientListener {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
// Public field for legacy parity (the switch platform republishes it).
|
||||
bool enabled{true};
|
||||
|
||||
void set_backend(ble_device_base::BLEGattConnection *backend) {
|
||||
this->backend_ = backend;
|
||||
backend->set_listener(this);
|
||||
}
|
||||
void set_address(uint64_t address);
|
||||
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
|
||||
void set_enabled(bool enabled);
|
||||
const char *address_str() const { return this->address_str_; }
|
||||
|
||||
void register_ble_node(BLEClientNode *node);
|
||||
// One registration spelling shared with the esp32 engine's bridge.
|
||||
void register_gatt_node(BLEClientNode *node) { this->register_ble_node(node); }
|
||||
|
||||
bool connected() const { return this->state_ == State::CONNECTED; }
|
||||
bool idle() const { return this->state_ == State::IDLE; }
|
||||
|
||||
/// Action-initiated connect (no sighting needed; uses the last captured
|
||||
/// address type, public until a sighting arrives). No-op unless idle.
|
||||
void connect();
|
||||
void disconnect();
|
||||
|
||||
/// Legacy-named deferral used by the automation twins: neutral listener
|
||||
/// callbacks run inside the backend's event drain, so automation chain
|
||||
/// continuations must leave that stack first.
|
||||
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
|
||||
|
||||
// Backend ops for nodes and actions - the frozen node-facing surface.
|
||||
// Only write_characteristic has an in-tree caller; subscribing means
|
||||
// notify_characteristic plus a CCCD write_descriptor (the caller's job
|
||||
// per the contract).
|
||||
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
|
||||
return this->backend_->write_characteristic(handle, data, len, response);
|
||||
}
|
||||
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
|
||||
int read_descriptor(uint16_t handle) { return this->backend_->read_descriptor(handle); }
|
||||
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
|
||||
return this->backend_->write_descriptor(handle, data, len);
|
||||
}
|
||||
int notify_characteristic(uint16_t handle, bool enable) {
|
||||
return this->backend_->notify_characteristic(handle, enable);
|
||||
}
|
||||
int pair() { return this->backend_->pair(); }
|
||||
int unpair() { return bluetooth_connection::unpair_device(this->address_); }
|
||||
|
||||
// Automation callback registration.
|
||||
template<typename F> void add_on_connect_callback(F &&callback) {
|
||||
this->connect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_disconnect_callback(F &&callback) {
|
||||
this->disconnect_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
// Fired when a connect attempt dies before being established; the user
|
||||
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
|
||||
template<typename F> void add_on_connect_failed_callback(F &&callback) {
|
||||
this->connect_failed_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
// ---- ble_device_base::ESPBTDeviceListener ----
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
|
||||
// ---- ble_device_base::GattClientListener ----
|
||||
void on_connection_state(bool connected, uint16_t mtu, int error) override;
|
||||
void on_service_discovery_done(int error) override;
|
||||
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
|
||||
void on_write_result(uint16_t handle, int error) override;
|
||||
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
|
||||
void on_notify_state(uint16_t handle, bool enabled, int error) override;
|
||||
void on_pairing_result(int status) override;
|
||||
|
||||
protected:
|
||||
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
|
||||
|
||||
void attempt_connect_();
|
||||
|
||||
// Group 1: pointers / containers
|
||||
ble_device_base::BLEGattConnection *backend_{nullptr};
|
||||
// Codegen-sized (ESPHOME_BLE_CLIENT_MAX_NODES); filled during setup.
|
||||
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> nodes_;
|
||||
|
||||
// Group 2: 8-byte types
|
||||
uint64_t address_{0};
|
||||
|
||||
// Group 3: callback managers (pointer-sized when empty)
|
||||
LazyCallbackManager<void()> connect_callbacks_;
|
||||
LazyCallbackManager<void()> disconnect_callbacks_;
|
||||
LazyCallbackManager<void()> connect_failed_callbacks_;
|
||||
|
||||
// Group 4: 4-byte types
|
||||
// Backoff so an undiscoverable database or a dead peer cannot produce a
|
||||
// battery-draining connect loop.
|
||||
ConnectBackoff backoff_;
|
||||
|
||||
// Group 5: arrays
|
||||
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
|
||||
|
||||
// Group 6: 1-byte types
|
||||
State state_{State::IDLE};
|
||||
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
|
||||
// Distinguishes a captured public type from the never-sighted default.
|
||||
bool address_type_known_{false};
|
||||
bool auto_connect_{true};
|
||||
// A user-initiated teardown in flight; its failure report is not a
|
||||
// connect failure and must not feed the backoff.
|
||||
bool cancel_requested_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
@@ -0,0 +1,68 @@
|
||||
// The single BLEClientNode both ble_client engines share. The neutral
|
||||
// callback surface is the one interface node components build on; the raw
|
||||
// esp32 surface below it remains for components that have not migrated yet.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#endif
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
class BLEClient;
|
||||
|
||||
class BLEClientNode {
|
||||
public:
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
// Neutral surface, delivered by both engines. The table is borrowed: copy
|
||||
// handles during on_connected(). All nodes see all completions; filter by
|
||||
// handle.
|
||||
// A node that disconnects from inside on_connected() aborts the fan-out;
|
||||
// the user's on_disconnect may then fire without a preceding on_connect.
|
||||
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
|
||||
virtual void on_disconnected() {}
|
||||
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
|
||||
// One in-flight registration per handle; its completion fans out to every
|
||||
// node, so a refused duplicate request still sees on_notify_state.
|
||||
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
|
||||
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
|
||||
virtual void on_write_result(uint16_t handle, int error) {}
|
||||
virtual void on_pairing_result(int status) {}
|
||||
#endif
|
||||
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
|
||||
// Legacy raw surface; components overriding these need the legacy engine
|
||||
// until migrated to the neutral surface above.
|
||||
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {}
|
||||
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
|
||||
virtual void loop() {}
|
||||
// This should be transitioned to Established once the node no longer needs
|
||||
// the services/descriptors/characteristics of the parent client. This will
|
||||
// allow some memory to be freed.
|
||||
// The parent frees the peer's GATT cache once every node reports Established.
|
||||
// Never report Established while an operation that reads that cache is outstanding.
|
||||
// - esp_ble_gattc_register_for_notify() completes asynchronously.
|
||||
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
|
||||
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
|
||||
esp32_ble_tracker::ClientState node_state;
|
||||
#endif
|
||||
|
||||
BLEClient *parent() const { return this->parent_; }
|
||||
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
|
||||
|
||||
protected:
|
||||
BLEClient *parent_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
@@ -0,0 +1,172 @@
|
||||
// The ble_client.ble_write action: a node on the platform-neutral interface,
|
||||
// so one implementation serves both engines (the esp32 bridge and the
|
||||
// neutral engine).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_CLIENT_GATT_NODES
|
||||
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
// One of the two engine headers resolves per build.
|
||||
#include "ble_client.h"
|
||||
#include "ble_client_gatt.h"
|
||||
#include "ble_client_node.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
static const char *const BLE_WRITE_TAG = "ble_client.automation";
|
||||
|
||||
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
|
||||
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
|
||||
|
||||
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientWriteAction(BLEClient *ble_client) {
|
||||
ble_client->register_gatt_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
|
||||
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
|
||||
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
|
||||
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
|
||||
this->value_.func = func;
|
||||
this->len_ = -1; // Sentinel value indicates template mode
|
||||
}
|
||||
|
||||
// Store pointer to static data in flash (no RAM copy)
|
||||
void set_value_simple(const uint8_t *data, size_t len) {
|
||||
this->value_.data = data;
|
||||
this->len_ = len; // Length >= 0 indicates static mode
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {}
|
||||
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->var_ = std::make_tuple(x...);
|
||||
|
||||
bool result;
|
||||
if (this->len_ >= 0) {
|
||||
result = this->write(this->value_.data, this->len_);
|
||||
} else {
|
||||
std::vector<uint8_t> value = this->value_.func(x...);
|
||||
result = this->write(value.data(), value.size());
|
||||
}
|
||||
|
||||
// on write failure, continue the automation chain rather than stopping so
|
||||
// that e.g. disconnect can work.
|
||||
if (!result)
|
||||
this->play_next_(x...);
|
||||
}
|
||||
|
||||
// Initiate the write; the completion arrives in on_write_result. The
|
||||
// response-less path can complete synchronously inside the call, so the
|
||||
// handle is armed before the backend is touched.
|
||||
bool write(const uint8_t *data, size_t len) {
|
||||
if (!this->ble_client_->connected()) {
|
||||
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
|
||||
return false;
|
||||
}
|
||||
if (!this->resolved_) {
|
||||
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
|
||||
return false;
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
|
||||
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
|
||||
#endif
|
||||
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
|
||||
if (err != 0) {
|
||||
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void on_connected(const ble_device_base::GattServiceTable &table) override {
|
||||
// Resolution comes from this table only; a re-discovery must not keep a
|
||||
// stale handle.
|
||||
this->resolved_ = false;
|
||||
this->char_handle_ = 0;
|
||||
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
|
||||
const auto *chr =
|
||||
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
|
||||
if (chr == nullptr) {
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
char service_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
|
||||
this->service_uuid_.to_str(service_buf));
|
||||
return;
|
||||
}
|
||||
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
|
||||
this->write_response_ = true;
|
||||
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
|
||||
this->write_response_ = false;
|
||||
} else {
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
|
||||
return;
|
||||
}
|
||||
this->char_handle_ = chr->value_handle;
|
||||
this->resolved_ = true;
|
||||
char char_buf[ble_device_base::UUID_STR_LEN];
|
||||
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
|
||||
this->ble_client_->address_str());
|
||||
}
|
||||
|
||||
void on_disconnected() override {
|
||||
this->resolved_ = false;
|
||||
this->char_handle_ = 0;
|
||||
if (this->num_running_ != 0)
|
||||
this->stop_complex();
|
||||
}
|
||||
|
||||
void on_write_result(uint16_t handle, int error) override {
|
||||
if (this->num_running_ == 0) {
|
||||
return;
|
||||
}
|
||||
if (!this->resolved_ || handle != this->char_handle_) {
|
||||
// A parked chain waiting on a completion that never matches would
|
||||
// otherwise stall silently until disconnect.
|
||||
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
|
||||
this->char_handle_);
|
||||
return;
|
||||
}
|
||||
if (error != 0) {
|
||||
// Continue the chain (legacy parity) but leave a breadcrumb.
|
||||
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
|
||||
}
|
||||
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
}
|
||||
|
||||
private:
|
||||
BLEClient *ble_client_;
|
||||
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
|
||||
union Value {
|
||||
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
|
||||
const uint8_t *data; // Pointer to static data in flash
|
||||
} value_;
|
||||
ble_device_base::ESPBTUUID service_uuid_;
|
||||
ble_device_base::ESPBTUUID char_uuid_;
|
||||
std::tuple<Ts...> var_{};
|
||||
uint16_t char_handle_{};
|
||||
bool write_response_{false};
|
||||
bool resolved_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_CLIENT_GATT_NODES
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
/// Reconnect backoff after repeated connect/discovery failures, shared by
|
||||
/// both engines. 256 ms ticks in a uint16_t keep it 4 bytes; the ~4.7 h tick
|
||||
/// wrap can at worst reinstate one stale hold-off of a minute.
|
||||
class ConnectBackoff {
|
||||
public:
|
||||
bool holding_off() const {
|
||||
return this->failures_ != 0 && static_cast<uint16_t>(now() - this->start_) < this->failures_ * STEP_TICKS;
|
||||
}
|
||||
void register_failure(const char *address_str) {
|
||||
if (this->failures_ < MAX_STEPS)
|
||||
this->failures_++;
|
||||
this->start_ = now();
|
||||
esph_log_w("ble_client", "[%s] Holding off reconnect for %u s", address_str, this->failures_ * 10u);
|
||||
}
|
||||
void reset() { this->failures_ = 0; }
|
||||
|
||||
private:
|
||||
// ~10 s per consecutive failure, capped so a flapping peer retries within
|
||||
// a minute at worst.
|
||||
static constexpr uint16_t STEP_TICKS = 40; // x 256 ms
|
||||
static constexpr uint8_t MAX_STEPS = 6;
|
||||
static uint16_t now() { return static_cast<uint16_t>(millis() >> 8); }
|
||||
|
||||
uint16_t start_{0};
|
||||
uint8_t failures_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -0,0 +1,48 @@
|
||||
#include "ble_gatt_client.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ble_device_base");
|
||||
|
||||
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid) {
|
||||
// 32-bit range math: a corrupt first/count pair cannot wrap past the check.
|
||||
uint32_t end = uint32_t(service.first_characteristic) + service.characteristic_count;
|
||||
if (end > table.characteristic_count) {
|
||||
ESP_LOGW(TAG, "characteristic range out of bounds");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint32_t i = service.first_characteristic; i < end; i++) {
|
||||
if (table.characteristics[i].uuid == uuid)
|
||||
return &table.characteristics[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
|
||||
const ESPBTUUID &uuid) {
|
||||
uint32_t end = uint32_t(characteristic.first_descriptor) + characteristic.descriptor_count;
|
||||
if (end > table.descriptor_count) {
|
||||
// Corrupt range, not a missing descriptor.
|
||||
ESP_LOGW(TAG, "descriptor range out of bounds");
|
||||
return nullptr;
|
||||
}
|
||||
for (uint32_t i = characteristic.first_descriptor; i < end; i++) {
|
||||
if (table.descriptors[i].uuid == uuid)
|
||||
return &table.descriptors[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
|
||||
const GattDescriptor *desc = find_descriptor(table, characteristic, ESPBTUUID::from_uint16(CCCD_UUID));
|
||||
return desc != nullptr ? desc->handle : 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
@@ -11,13 +11,16 @@
|
||||
// interface. All listener calls are delivered on the ESPHome main loop;
|
||||
// borrowed data pointers are valid only for the duration of the call.
|
||||
//
|
||||
// Error domain (plain int, forwarded to the API without translation):
|
||||
// Error domain (plain int, forwarded to the API without translation, so the
|
||||
// values are wire-frozen - API clients interpret them):
|
||||
// 0 success
|
||||
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
|
||||
// 1..0x11 ATT error codes (Bluetooth spec) - reserved; a backend whose
|
||||
// native error codes land in this window must remap them out
|
||||
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
|
||||
// ESP_FAIL from the stack shares this value; both read as a
|
||||
// failed, unusable connection on the client side)
|
||||
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
|
||||
// -1..-15 reserved for future contract sentinels
|
||||
// anything else: platform stack error/status code, surfaced opaquely.
|
||||
// Connection events carry HCI status/disconnect reason codes (same code
|
||||
// space on every controller).
|
||||
@@ -99,9 +102,18 @@ class GattClientListener {
|
||||
// The BLEGattConnection op surface, asserted where the alias binds
|
||||
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
|
||||
// through the listener) or a synchronous error (busy, not connected, stack
|
||||
// rejection); one operation may be outstanding at a time. Semantics beyond
|
||||
// the signatures:
|
||||
// rejection); one operation may be outstanding at a time. An accepted
|
||||
// operation's completion is delivered from the event loop, NEVER
|
||||
// synchronously from inside the op call - a synchronous terminal
|
||||
// on_connection_state from within gatt_disconnect() would re-enter the
|
||||
// consumer mid-teardown. Semantics beyond the signatures:
|
||||
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
|
||||
// Returning 0 means the request is accepted, not that the radio acted: the
|
||||
// backend owns integration with its platform's scan/connect arbitration
|
||||
// (Bluedroid parks the request for the tracker's promote loop, which owns
|
||||
// scan-stop/coex/one-connect-at-a-time; the rp2 backend opens immediately
|
||||
// and relies on sighting-gated consumers). Consumers must not assume
|
||||
// connect timing.
|
||||
// - gatt_disconnect: also cancels a connect in progress (named to coexist
|
||||
// with a platform stack's own void disconnect() on one backend class).
|
||||
// Nonzero means nothing to tear down and no completion will follow; an
|
||||
@@ -143,6 +155,41 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
|
||||
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
|
||||
};
|
||||
|
||||
// ---- service table lookup helpers ----
|
||||
//
|
||||
// Neutral, bounds-checked walks over a materialized GattServiceTable for
|
||||
// direct consumers that resolve a known device's handles by UUID (streaming
|
||||
// consumers forward the raw database and never need these). Linear search:
|
||||
// the table exists only between discovery and release_services(), for one
|
||||
// small known device.
|
||||
|
||||
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
|
||||
static constexpr uint16_t CCCD_UUID = 0x2902;
|
||||
|
||||
// Characteristic property bits (the Bluetooth-spec declaration byte carried
|
||||
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
|
||||
// exist on the other platforms).
|
||||
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
|
||||
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
|
||||
|
||||
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
|
||||
for (uint16_t i = 0; i < table.service_count; i++) {
|
||||
if (table.services[i].uuid == uuid)
|
||||
return &table.services[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
|
||||
const ESPBTUUID &uuid);
|
||||
|
||||
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
|
||||
const ESPBTUUID &uuid);
|
||||
|
||||
/// Handle of the characteristic's Client Characteristic Configuration
|
||||
/// descriptor (0x2902), or 0 when it has none.
|
||||
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic);
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
|
||||
#endif // USE_BLE_GATT_CLIENT
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Per-platform GATT connection backends and the helpers to embed one.
|
||||
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the
|
||||
Bluetooth proxy's codegen declares and registers the backend instances
|
||||
through gatt_client_schema()/hub_connection_schema() + new_gatt_backend().
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
|
||||
consumer's codegen declares and registers the backend instances — the
|
||||
Bluetooth proxy through its per-slot connection wrappers (a streaming
|
||||
consumer), and the neutral ble_client through gatt_client_schema() +
|
||||
new_gatt_backend().
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import rp2040_ble
|
||||
@@ -23,7 +25,8 @@ from esphome.types import ConfigType
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
"""ble_device_base plus the platform BLE stack the build's backend
|
||||
registers with (the Bluedroid header includes the tracker's), so
|
||||
consumers need not know. The platform-less arm serves manifest tooling."""
|
||||
consumers stay platform-blind. The platform-less arm serves tooling that
|
||||
resolves the manifest without a target."""
|
||||
if CORE.is_esp32:
|
||||
return ["ble_device_base", "esp32_ble_tracker"]
|
||||
if CORE.is_rp2:
|
||||
@@ -63,6 +66,8 @@ DOMAIN = "bluetooth_connection"
|
||||
@dataclass
|
||||
class _ConnectionData:
|
||||
rp2_backend_count: int = 0
|
||||
# GATT connection slots claimed this run, for the platform cap check.
|
||||
slot_consumers: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> _ConnectionData:
|
||||
@@ -119,6 +124,10 @@ class _PlatformBackend:
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
|
||||
# Selects the backend's alias-ladder arm (order-independent arms).
|
||||
define: str
|
||||
# The backend's on-demand materializer gate, when it has one.
|
||||
materializer_define: str | None = None
|
||||
|
||||
|
||||
# The single registry of platforms with a GATT client backend; a platform
|
||||
@@ -126,11 +135,20 @@ class _PlatformBackend:
|
||||
# platform's arm.
|
||||
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
|
||||
PLATFORM_ESP32: _PlatformBackend(
|
||||
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
|
||||
BluedroidGattClient,
|
||||
_esp32_schema_fragment,
|
||||
_esp32_register,
|
||||
"USE_BLE_GATT_BACKEND_BLUEDROID",
|
||||
materializer_define="USE_BLUEDROID_GATT_SERVICE_TABLE",
|
||||
),
|
||||
PLATFORM_RP2: _PlatformBackend(
|
||||
RP2GattClient, _rp2_schema_fragment, _rp2_register, "USE_BLE_GATT_BACKEND_RP2"
|
||||
),
|
||||
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
|
||||
}
|
||||
|
||||
# Gates dedicated-backend consumers (cv.only_on).
|
||||
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
|
||||
|
||||
|
||||
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
|
||||
key = platform if platform is not None else CORE.target_platform
|
||||
@@ -165,21 +183,97 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema:
|
||||
)
|
||||
|
||||
|
||||
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
def _charge_esp32_budget(count: int, consumer: str, config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble
|
||||
|
||||
esp32_ble.consume_connection_slots(count, consumer)(config)
|
||||
|
||||
|
||||
def _charge_rp2_budget(count: int, consumer: str, config: ConfigType) -> None:
|
||||
rp2040_ble.consume_connection_slots(count, consumer)(config)
|
||||
|
||||
|
||||
# Platforms whose BLE stack owns its own connection budget: the claim is
|
||||
# charged there and that stack's final validation is the one place an
|
||||
# overcommit is reported (never two messages for one misconfiguration). One
|
||||
# mapping drives both, so a platform can never be skipped without a charge.
|
||||
_STACK_BUDGETS: dict[str, Callable[[int, str, ConfigType], None]] = {
|
||||
PLATFORM_ESP32: _charge_esp32_budget,
|
||||
PLATFORM_RP2: _charge_rp2_budget,
|
||||
}
|
||||
|
||||
|
||||
def consume_gatt_slot(
|
||||
consumer: str, count: int = 1
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Validator claiming GATT connection slots - the one spelling for every
|
||||
claimant. The neutral ledger covers backend platforms without a stack
|
||||
budget (the cap check in FINAL_VALIDATE_SCHEMA)."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
_get_data().slot_consumers.extend([consumer] * count)
|
||||
if (charge := _STACK_BUDGETS.get(CORE.target_platform)) is not None:
|
||||
charge(count, consumer, config)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _validate_slot_totals(config: ConfigType) -> ConfigType:
|
||||
# Skipped in testing mode so grouped component builds can co-exist
|
||||
# (mirrors esp32_ble.validate_connection_slots).
|
||||
if CORE.testing_mode:
|
||||
return config
|
||||
if CORE.target_platform in _STACK_BUDGETS:
|
||||
return config
|
||||
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
|
||||
# Any backend platform without a stack budget must carry a cap here
|
||||
# or fail loudly, never fail open.
|
||||
if CORE.target_platform in _PLATFORM_BACKENDS:
|
||||
raise cv.Invalid(
|
||||
f"{CORE.target_platform} has a GATT backend but no slot cap "
|
||||
"in HUB_MAX_CONNECTIONS"
|
||||
)
|
||||
return config
|
||||
claimed = _get_data().slot_consumers
|
||||
if len(claimed) > cap:
|
||||
raise cv.Invalid(
|
||||
f"{CORE.target_platform} supports at most {cap} GATT client "
|
||||
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
|
||||
|
||||
|
||||
async def new_gatt_backend(
|
||||
config: ConfigType, *, service_table: bool = True
|
||||
) -> cg.MockObj:
|
||||
"""Instantiate the backend declared by gatt_client_schema() and register
|
||||
it with its platform stack. The connection slot is claimed at validation
|
||||
(the proxy's slot validators), not here.
|
||||
(the consume_gatt_slot validators), not here.
|
||||
|
||||
service_table is honored by the Bluedroid backend only: forward
|
||||
scaffolding for the first esp32 direct consumer, load-bearing on no
|
||||
current build (rp2 ignores the define and always materializes - its
|
||||
proxy hub streams through get_service_table(), so it must keep the
|
||||
materializer regardless of the flag).
|
||||
"""
|
||||
from esphome.components import ble_device_base
|
||||
|
||||
entry = _backend_entry()
|
||||
ble_device_base.request_gatt_client()
|
||||
cg.add_define(entry.define)
|
||||
if service_table and entry.materializer_define is not None:
|
||||
cg.add_define(entry.materializer_define)
|
||||
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
|
||||
# The backend is the slot's real Component: component keys from the
|
||||
# connection entry (setup_priority, ...) apply to it. Consumers whose own
|
||||
# schema carries keys that register_component would misapply to the
|
||||
# backend (e.g. a polling interval) must not put them in this config.
|
||||
await cg.register_component(backend, config)
|
||||
await _backend_entry().register(backend, config)
|
||||
await entry.register(backend, config)
|
||||
return backend
|
||||
|
||||
|
||||
@@ -187,6 +281,7 @@ async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
# list (this module cannot import bluetooth_proxy to derive it).
|
||||
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
|
||||
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
|
||||
"gatt_service_table_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
|
||||
# Every hub platform the proxy admits (the file compiles empty where
|
||||
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
|
||||
# cannot hit a missing-symbol trap here.
|
||||
|
||||
@@ -46,7 +46,7 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
|
||||
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
|
||||
@@ -65,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) {
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
#endif // USE_ESP32 && USE_BLE_GATT_CLIENT
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
|
||||
|
||||
@@ -48,15 +48,16 @@ static constexpr conn_err_t CONN_OK = 0;
|
||||
// GATT contract so backend and wrapper cannot drift.
|
||||
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
|
||||
// What the platform's connection backend supports beyond GATT operations;
|
||||
// the proxy derives its feature flags and legacy version from these.
|
||||
#if defined(USE_ESP32)
|
||||
// What the build's connection backend supports beyond GATT operations; the
|
||||
// proxy derives its feature flags and legacy version from these. Keyed on
|
||||
// the backend define, never the platform, so a second backend on one
|
||||
// platform carries its own facts.
|
||||
#if defined(USE_BLE_GATT_BACKEND_BLUEDROID)
|
||||
static constexpr bool SUPPORTS_PAIRING = true;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
|
||||
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
#elif defined(USE_BLE_GATT_BACKEND_RP2)
|
||||
// The rp2 BTstack backend pairs (just works + bonding); it has no service
|
||||
// cache to clear. Keyed on the backend, not the generic client define, so a
|
||||
// future backend without pairing keeps the stub arm below.
|
||||
// cache to clear.
|
||||
static constexpr bool SUPPORTS_PAIRING = true;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#else
|
||||
@@ -64,13 +65,14 @@ static constexpr bool SUPPORTS_PAIRING = false;
|
||||
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
|
||||
#endif
|
||||
|
||||
// Address-scoped (not connection-scoped) maintenance requests.
|
||||
#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
// Address-scoped (not connection-scoped) maintenance requests; keyed on the
|
||||
// stack (the calls need no backend instance).
|
||||
#if (defined(USE_ESP32_BLE) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t unpair_device(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
#endif
|
||||
#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
conn_err_t clear_gatt_cache(uint64_t address);
|
||||
#else
|
||||
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
// The in-place streamer serves the proxy's service-discovery API; backend-only
|
||||
// builds compile without the proxy headers or the streamer.
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
#include "bluetooth_connection.h"
|
||||
#include "bluetooth_connection_hub.h"
|
||||
|
||||
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
|
||||
@@ -348,6 +349,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
|
||||
|
||||
void BluedroidGattClient::release_services() {
|
||||
this->service_total_ = 0;
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
this->table_.free();
|
||||
#endif
|
||||
// Always set: terminates any in-flight stream on every cache config.
|
||||
this->services_released_ = true;
|
||||
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
|
||||
@@ -360,6 +364,24 @@ void BluedroidGattClient::release_services() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
|
||||
// Lifetime: every teardown path (CLOSE_EVT, the safety timeout, stack-down,
|
||||
// passive DISCONNECT) routes through release_services(), so a materialized
|
||||
// table cannot outlive its link.
|
||||
if (this->table_.empty() &&
|
||||
(this->services_released_ || this->service_total_ == 0 ||
|
||||
!this->table_.build(this->gattc_if_, this->conn_id_, this->service_total_, this->connection_index_))) {
|
||||
// Released / no services / failed build all collapse to empty; the
|
||||
// build failures warned above, log the quiet two.
|
||||
ESP_LOGD(TAG, "[%d] No service table (released=%d, services=%u)", this->connection_index_, this->services_released_,
|
||||
this->service_total_);
|
||||
return {};
|
||||
}
|
||||
return this->table_.view();
|
||||
}
|
||||
#endif // USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
|
||||
@@ -406,6 +428,11 @@ void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
|
||||
// ---- service streaming ----
|
||||
|
||||
int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) {
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
// Re-discovery moves the counts the table view derives offsets from; free
|
||||
// the stale table.
|
||||
this->table_.free();
|
||||
#endif
|
||||
// Step down from the fast discovery params.
|
||||
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
|
||||
if (status != ESP_GATT_OK) {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
#include "gatt_service_table_bluedroid.h"
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -76,11 +79,16 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
int notify_characteristic(uint16_t handle, bool enable);
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
// Contract stub: the proxy streams in place; the on-demand materializer
|
||||
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
|
||||
// this stub gets an empty table indistinguishable from a service-less
|
||||
// peer - do not ship one against this backend before the materializer.
|
||||
// On-demand table for direct consumers; the proxy streams instead, so the
|
||||
// materializer compiles only under USE_BLUEDROID_GATT_SERVICE_TABLE (emitted by
|
||||
// direct-consumer codegen, never by the proxy).
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
#else
|
||||
// A direct consumer reaching this stub misconfigured its codegen
|
||||
// (service_table=False): the empty table reads as a service-less peer.
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
#endif
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
@@ -110,6 +118,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
|
||||
|
||||
// Group 1: pointers / composed objects
|
||||
ble_device_base::GattClientListener *listener_{nullptr};
|
||||
#ifdef USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
BluedroidServiceTable table_;
|
||||
#endif
|
||||
// Group 2: 4-byte types
|
||||
uint32_t disconnecting_started_{0};
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
|
||||
#if defined(USE_RP2040_BLE)
|
||||
// Arms are keyed on codegen-emitted per-backend defines (_PLATFORM_BACKENDS
|
||||
// in __init__.py), so they are order-independent.
|
||||
#if defined(USE_BLE_GATT_BACKEND_RP2)
|
||||
#include "bluetooth_connection_rp2.h"
|
||||
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
|
||||
#elif defined(USE_ESP32_BLE)
|
||||
#elif defined(USE_BLE_GATT_BACKEND_BLUEDROID)
|
||||
#include "bluetooth_connection_bluedroid.h"
|
||||
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
|
||||
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "gatt_service_table_bluedroid.h"
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "bluetooth_connection");
|
||||
|
||||
// A stack that never reports end-of-range would otherwise walk forever.
|
||||
static constexpr uint16_t MAX_DESCRIPTORS_PER_CHARACTERISTIC = 64;
|
||||
|
||||
// Shared enumeration for both build passes: an identical walk order is what
|
||||
// lets the counting pass size the block the filling pass fills.
|
||||
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool BluedroidServiceTable::walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
|
||||
for (uint16_t s = 0; s < this->service_total_; s++) {
|
||||
esp_gattc_service_elem_t svc;
|
||||
uint16_t svc_count = 1;
|
||||
auto svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s);
|
||||
if (svc_status != ESP_GATT_OK || svc_count == 0) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_service", svc_status);
|
||||
return false;
|
||||
}
|
||||
if (!on_service(s, svc)) {
|
||||
return false;
|
||||
}
|
||||
uint16_t svc_chars = 0;
|
||||
auto count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
|
||||
svc.start_handle, svc.end_handle, 0, &svc_chars);
|
||||
if (count_status != ESP_GATT_OK) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_attr_count", count_status);
|
||||
return false;
|
||||
}
|
||||
for (uint16_t c = 0; c < svc_chars; c++) {
|
||||
esp_gattc_char_elem_t chr;
|
||||
uint16_t char_count = 1;
|
||||
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
|
||||
&char_count, c);
|
||||
if (status != ESP_GATT_OK || char_count == 0) {
|
||||
// An early terminator contradicts svc_chars from the same cache;
|
||||
// never build a silently truncated table.
|
||||
this->log_walk_warning_("esp_ble_gattc_get_all_char", status);
|
||||
return false;
|
||||
}
|
||||
if (!on_char(svc, chr)) {
|
||||
return false;
|
||||
}
|
||||
for (uint16_t d = 0;; d++) {
|
||||
if (d == MAX_DESCRIPTORS_PER_CHARACTERISTIC) {
|
||||
// A stack that never reports end-of-range; fail like every other
|
||||
// inconsistency instead of truncating the table silently.
|
||||
ESP_LOGW(TAG, "[%d] Descriptor walk exceeded %u entries", this->log_index_,
|
||||
MAX_DESCRIPTORS_PER_CHARACTERISTIC);
|
||||
return false;
|
||||
}
|
||||
esp_gattc_descr_elem_t desc;
|
||||
uint16_t desc_count = 1;
|
||||
auto desc_status =
|
||||
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
|
||||
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
|
||||
break;
|
||||
}
|
||||
if (desc_status != ESP_GATT_OK || desc_count == 0) {
|
||||
this->log_walk_warning_("esp_ble_gattc_get_all_descr", desc_status);
|
||||
return false;
|
||||
}
|
||||
if (!on_desc(chr, desc)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidServiceTable::count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total) {
|
||||
uint16_t primary = 0;
|
||||
uint16_t secondary = 0;
|
||||
if (esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001, 0xFFFF, 0, &primary) !=
|
||||
ESP_GATT_OK ||
|
||||
esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary) !=
|
||||
ESP_GATT_OK) {
|
||||
// A failed count must not read as an authoritative empty database.
|
||||
return false;
|
||||
}
|
||||
*total = primary + secondary;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BluedroidServiceTable::build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index) {
|
||||
this->free();
|
||||
this->gattc_if_ = gattc_if;
|
||||
this->conn_id_ = conn_id;
|
||||
this->service_total_ = service_total;
|
||||
this->log_index_ = log_index;
|
||||
|
||||
// Pass 1: count, so one exact-size block holds the whole table.
|
||||
uint16_t char_total = 0;
|
||||
uint16_t desc_total = 0;
|
||||
bool counted = this->walk_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
|
||||
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
|
||||
char_total++;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
|
||||
desc_total++;
|
||||
return true;
|
||||
});
|
||||
if (!counted) {
|
||||
ESP_LOGW(TAG, "[%d] Service table walk failed during count", this->log_index_);
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
|
||||
// The arrays share one block; carving stays aligned because each struct's
|
||||
// strictest member is the UUID and array sizes are multiples of it.
|
||||
static_assert(alignof(ble_device_base::GattService) >= alignof(ble_device_base::GattCharacteristic) &&
|
||||
alignof(ble_device_base::GattCharacteristic) >= alignof(ble_device_base::GattDescriptor));
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
|
||||
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
this->storage_ = allocator.allocate(total_bytes);
|
||||
if (this->storage_ == nullptr) {
|
||||
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->log_index_,
|
||||
static_cast<unsigned>(total_bytes));
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->storage_);
|
||||
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes);
|
||||
auto *descriptors = reinterpret_cast<ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes);
|
||||
|
||||
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
|
||||
// means the cached database changed between the passes; fail the build
|
||||
// rather than serve an inconsistent table (the consumer retries).
|
||||
uint16_t char_index = 0;
|
||||
uint16_t desc_index = 0;
|
||||
ble_device_base::GattService *cur_service = nullptr;
|
||||
ble_device_base::GattCharacteristic *cur_char = nullptr;
|
||||
bool filled = this->walk_(
|
||||
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
|
||||
cur_service = &services[s];
|
||||
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
|
||||
cur_service->start_handle = svc.start_handle;
|
||||
cur_service->end_handle = svc.end_handle;
|
||||
cur_service->first_characteristic = char_index;
|
||||
cur_service->characteristic_count = 0;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
|
||||
if (char_index >= char_total) {
|
||||
return false;
|
||||
}
|
||||
cur_char = &characteristics[char_index++];
|
||||
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
|
||||
cur_char->value_handle = chr.char_handle;
|
||||
// Bluedroid addresses descriptors by characteristic handle, so the
|
||||
// table's end_handle only needs the service-bounded upper bound.
|
||||
cur_char->end_handle = svc.end_handle;
|
||||
cur_char->properties = chr.properties;
|
||||
cur_char->first_descriptor = desc_index;
|
||||
cur_char->descriptor_count = 0;
|
||||
cur_service->characteristic_count++;
|
||||
return true;
|
||||
},
|
||||
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
|
||||
if (desc_index >= desc_total) {
|
||||
return false;
|
||||
}
|
||||
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
|
||||
descriptors[desc_index].handle = desc.handle;
|
||||
desc_index++;
|
||||
cur_char->descriptor_count++;
|
||||
return true;
|
||||
});
|
||||
if (!filled || char_index != char_total || desc_index != desc_total) {
|
||||
// Walk error or the database changed between passes; better an empty
|
||||
// table than a corrupt one.
|
||||
ESP_LOGW(TAG, "[%d] Service table walk mismatch, discarding", this->log_index_);
|
||||
this->free();
|
||||
return false;
|
||||
}
|
||||
this->char_total_ = char_total;
|
||||
this->desc_total_ = desc_total;
|
||||
return true;
|
||||
}
|
||||
|
||||
void BluedroidServiceTable::log_walk_warning_(const char *operation, int code) {
|
||||
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->log_index_, operation, code);
|
||||
}
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
@@ -0,0 +1,82 @@
|
||||
// Owning two-pass materializer of one Bluedroid GATT database snapshot into
|
||||
// the neutral GattServiceTable layout, shared by the BluedroidGattClient
|
||||
// backend and ble_client's esp32 engine.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUEDROID_GATT_SERVICE_TABLE)
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_gatt_client.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
class BluedroidServiceTable {
|
||||
public:
|
||||
~BluedroidServiceTable() { this->free(); }
|
||||
// Owns storage_; a copy would double-free. The default constructor is user
|
||||
// provided, not "= default", so value-initializing the enclosing backend
|
||||
// cannot zero-fill .bss that is already zero.
|
||||
BluedroidServiceTable() {}
|
||||
BluedroidServiceTable(const BluedroidServiceTable &) = delete;
|
||||
BluedroidServiceTable &operator=(const BluedroidServiceTable &) = delete;
|
||||
|
||||
/// The service count build() requires: the stack's PRIMARY+SECONDARY
|
||||
/// attribute totals, never the SEARCH_RES event count.
|
||||
static bool count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total);
|
||||
|
||||
/// Two-pass build from the stack's cached database (service_total from
|
||||
/// count_services()). log_index labels warnings. Frees any previous table
|
||||
/// first; on failure the table is left empty.
|
||||
bool build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index);
|
||||
|
||||
// The view is carved from the storage block and the counts on each call
|
||||
// (a cold path) rather than cached, saving a per-instance table member.
|
||||
ble_device_base::GattServiceTable view() const {
|
||||
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
|
||||
size_t char_bytes = this->char_total_ * sizeof(ble_device_base::GattCharacteristic);
|
||||
return {reinterpret_cast<const ble_device_base::GattService *>(this->storage_),
|
||||
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->storage_ + svc_bytes),
|
||||
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->storage_ + svc_bytes + char_bytes),
|
||||
this->service_total_,
|
||||
this->char_total_,
|
||||
this->desc_total_};
|
||||
}
|
||||
|
||||
// Always resets the counts: a failed build must never leave a non-zero
|
||||
// service_total_ behind a null table.
|
||||
void free() {
|
||||
if (this->storage_ != nullptr) {
|
||||
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
|
||||
allocator.deallocate(this->storage_, 0);
|
||||
this->storage_ = nullptr;
|
||||
}
|
||||
this->service_total_ = 0;
|
||||
this->char_total_ = 0;
|
||||
this->desc_total_ = 0;
|
||||
}
|
||||
|
||||
bool empty() const { return this->storage_ == nullptr; }
|
||||
|
||||
private:
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool walk_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
|
||||
void log_walk_warning_(const char *operation, int code);
|
||||
|
||||
uint8_t *storage_{nullptr};
|
||||
uint16_t service_total_{0};
|
||||
uint16_t char_total_{0};
|
||||
uint16_t desc_total_{0};
|
||||
// Walk context, set by build().
|
||||
uint16_t conn_id_{0};
|
||||
esp_gatt_if_t gattc_if_{}; // uint8_t width
|
||||
uint8_t log_index_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT && USE_BLUEDROID_GATT_SERVICE_TABLE
|
||||
@@ -99,9 +99,15 @@ def _esp32_config_schema() -> cv.All:
|
||||
raise cv.Invalid(
|
||||
"Connections can only be used if the proxy is set to active"
|
||||
)
|
||||
# Explicit entries claim slots like the generated ones; dev
|
||||
# historically skipped this, letting an explicit-connections
|
||||
# config evade the controller budget.
|
||||
bluetooth_connection.consume_gatt_slot(
|
||||
"bluetooth_proxy", len(config[CONF_CONNECTIONS])
|
||||
)(config)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
|
||||
@@ -158,14 +164,14 @@ def _rp2_config_schema() -> cv.All:
|
||||
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
|
||||
|
||||
def populate_connections(config: ConfigType) -> ConfigType:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
# One wrapper + backend pair per slot, declared during validation so
|
||||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||||
@@ -215,7 +221,9 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||||
# sends those requests and their handlers and encoders are dead.
|
||||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||||
for connection_conf in connections:
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
backend = await bluetooth_connection.new_gatt_backend(
|
||||
connection_conf, service_table=False
|
||||
)
|
||||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
cg.add(connection.set_backend(backend))
|
||||
cg.add(var.register_connection(connection))
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
#include "esphome/components/bme280_base/bme280_base.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bme280_i2c {
|
||||
|
||||
static const char *const TAG = "bme280_i2c.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "bme280_i2c.sensor");
|
||||
|
||||
class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice {
|
||||
bool read_byte(uint8_t a_register, uint8_t *data) override;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
#include "esphome/components/bmp280_base/bmp280_base.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bmp280_i2c {
|
||||
|
||||
static const char *const TAG = "bmp280_i2c.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "bmp280_i2c.sensor");
|
||||
|
||||
/// This class implements support for the BMP280 Temperature+Pressure i2c sensor.
|
||||
class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
#include "esphome/components/bmp581_base/bmp581_base.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bmp581_i2c {
|
||||
|
||||
static const char *const TAG = "bmp581_i2c.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "bmp581_i2c.sensor");
|
||||
|
||||
/// This class implements support for the BMP581 Temperature+Pressure i2c sensor.
|
||||
class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_ID,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["sensor"]
|
||||
CODEOWNERS = ["@limpkin"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
DOMAIN = "bq76972"
|
||||
MULTI_CONF = True
|
||||
|
||||
bq76972_ns = cg.esphome_ns.namespace("bq76972")
|
||||
BQ76972Component = bq76972_ns.class_(
|
||||
"BQ76972Component", cg.PollingComponent, i2c.I2CDevice
|
||||
)
|
||||
|
||||
CONF_REG_DISABLED = "reg_disabled"
|
||||
CONF_CRC_ENABLED = "crc_enabled"
|
||||
CONF_STACK_VOLTAGE = "stack_voltage"
|
||||
|
||||
# Ordered as the chip reports them, starting at command 0x68
|
||||
CONF_TEMPERATURES = [
|
||||
"internal_temperature",
|
||||
"cfetoff_temperature",
|
||||
"dfetoff_temperature",
|
||||
"alert_temperature",
|
||||
"ts1_temperature",
|
||||
"ts2_temperature",
|
||||
"ts3_temperature",
|
||||
"hdq_temperature",
|
||||
"dchg_temperature",
|
||||
"ddsg_temperature",
|
||||
]
|
||||
|
||||
CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)]
|
||||
|
||||
voltage_sensor_schema = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_VOLT,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
temp_sensor_schema = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BQ76972Component),
|
||||
cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean,
|
||||
cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean,
|
||||
cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema,
|
||||
**{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES},
|
||||
**{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES},
|
||||
}
|
||||
)
|
||||
.extend(i2c.i2c_device_schema(0x08))
|
||||
.extend(cv.polling_component_schema("10s"))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED]))
|
||||
cg.add(var.set_reg_disable(config[CONF_REG_DISABLED]))
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor)
|
||||
|
||||
for idx, key in enumerate(CONF_TEMPERATURES):
|
||||
if (conf := config.get(key)) is not None:
|
||||
sens = await sensor.new_sensor(conf)
|
||||
cg.add(var.set_temperature_sensor(sens, idx))
|
||||
|
||||
for idx, key in enumerate(CONF_CELL_VOLTAGES):
|
||||
if (conf := config.get(key)) is not None:
|
||||
sens = await sensor.new_sensor(conf)
|
||||
cg.add(var.set_cell_sensor(sens, idx))
|
||||
|
||||
|
||||
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BQ76972Component),
|
||||
cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address),
|
||||
},
|
||||
key=CONF_ADDRESS,
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"bq76972.program_i2c_address",
|
||||
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA,
|
||||
automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8),
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "bq76972.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bq76972 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "bq76972");
|
||||
|
||||
static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte
|
||||
static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40;
|
||||
static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length
|
||||
static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12;
|
||||
static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14;
|
||||
static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34;
|
||||
static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68;
|
||||
static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090;
|
||||
static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092;
|
||||
static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236;
|
||||
static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237;
|
||||
static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A;
|
||||
static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC;
|
||||
// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N
|
||||
static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9;
|
||||
static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default
|
||||
static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07;
|
||||
|
||||
// 16 words with a CRC byte after every data byte
|
||||
static constexpr size_t BQ76972_MAX_TRANSFER = 64;
|
||||
static constexpr uint8_t BQ76972_MAX_POLLS = 50;
|
||||
|
||||
static float raw_to_celsius(uint16_t raw) { return static_cast<int16_t>(raw) * 0.1f - 273.15f; }
|
||||
|
||||
bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) {
|
||||
if (!this->crc_mode_)
|
||||
return this->read_bytes(start_register, data, len);
|
||||
|
||||
// With CRC enabled, every data byte is followed by a CRC byte
|
||||
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
|
||||
if (len == 0 || len * 2 > buffer.size())
|
||||
return false;
|
||||
if (!this->read_bytes(start_register, buffer.data(), len * 2))
|
||||
return false;
|
||||
|
||||
// The first CRC also covers the write address, register and read address
|
||||
const uint8_t crc0_input[4] = {static_cast<uint8_t>(this->address_ << 1), start_register,
|
||||
static_cast<uint8_t>((this->address_ << 1) | 1), buffer[0]};
|
||||
if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) {
|
||||
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register);
|
||||
return false;
|
||||
}
|
||||
data[0] = buffer[0];
|
||||
for (size_t i = 1; i < len; ++i) {
|
||||
if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) {
|
||||
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast<unsigned>(i));
|
||||
return false;
|
||||
}
|
||||
data[i] = buffer[i * 2];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) {
|
||||
if (!this->crc_mode_)
|
||||
return this->write_bytes(start_register, data, len);
|
||||
|
||||
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
|
||||
if (len == 0 || len * 2 > buffer.size())
|
||||
return false;
|
||||
|
||||
// The first CRC also covers the write address and register
|
||||
const uint8_t crc0_input[3] = {static_cast<uint8_t>(this->address_ << 1), start_register, data[0]};
|
||||
buffer[0] = data[0];
|
||||
buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true);
|
||||
for (size_t i = 1; i < len; ++i) {
|
||||
buffer[i * 2] = data[i];
|
||||
buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true);
|
||||
}
|
||||
return this->write_bytes(start_register, buffer.data(), len * 2);
|
||||
}
|
||||
|
||||
bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) {
|
||||
std::array<uint8_t, BQ76972_MAX_TRANSFER / 2> bytes;
|
||||
if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2))
|
||||
return false;
|
||||
for (size_t i = 0; i < num_words; i++)
|
||||
data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BQ76972Component::send_subcommand_(uint16_t subcommand) {
|
||||
const uint8_t bytes[2] = {static_cast<uint8_t>(subcommand & 0xFF), static_cast<uint8_t>(subcommand >> 8)};
|
||||
return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2);
|
||||
}
|
||||
|
||||
bool BQ76972Component::wait_for_subcommand_() {
|
||||
uint8_t bytes[2];
|
||||
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
|
||||
if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2))
|
||||
return false;
|
||||
if (bytes[0] != 0xFF && bytes[1] != 0xFF)
|
||||
return true;
|
||||
delay(1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool BQ76972Component::wait_for_cfgupdate_() {
|
||||
uint16_t status;
|
||||
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
|
||||
if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1))
|
||||
return false;
|
||||
if (status & 0x0001)
|
||||
return true;
|
||||
delay(1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) {
|
||||
return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() &&
|
||||
this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len);
|
||||
}
|
||||
|
||||
bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) {
|
||||
if (!this->send_subcommand_(subcommand))
|
||||
return false;
|
||||
if (len == 0)
|
||||
return true;
|
||||
if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len))
|
||||
return false;
|
||||
|
||||
// Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data
|
||||
uint8_t sum = static_cast<uint8_t>(subcommand & 0xFF) + static_cast<uint8_t>(subcommand >> 8);
|
||||
for (size_t i = 0; i < len; ++i)
|
||||
sum += data[i];
|
||||
const uint8_t tail[2] = {static_cast<uint8_t>(~sum), static_cast<uint8_t>(4 + len)};
|
||||
return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2);
|
||||
}
|
||||
|
||||
bool BQ76972Component::enter_cfg_update_() {
|
||||
if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0))
|
||||
return false;
|
||||
delay(10);
|
||||
return this->wait_for_cfgupdate_();
|
||||
}
|
||||
|
||||
bool BQ76972Component::exit_cfg_update_() {
|
||||
return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0);
|
||||
}
|
||||
|
||||
bool BQ76972Component::regs_disabled_(bool &out) {
|
||||
uint8_t reg12;
|
||||
uint8_t reg0;
|
||||
if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, ®12, 1) ||
|
||||
!this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, ®0, 1))
|
||||
return false;
|
||||
out = reg12 == 0 && (reg0 & 0x01) == 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
const LogString *BQ76972Component::configure_() {
|
||||
uint16_t internal_temp;
|
||||
if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1))
|
||||
return LOG_STR(ESP_LOG_MSG_COMM_FAIL);
|
||||
|
||||
// Only write the regulator config when it differs, see
|
||||
// https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout
|
||||
bool regs_disabled = true;
|
||||
if (this->reg_disable_ && !this->regs_disabled_(regs_disabled))
|
||||
return LOG_STR("Reading regulator config failed");
|
||||
const bool write_regs = !regs_disabled;
|
||||
|
||||
bool write_thermistors = false;
|
||||
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
|
||||
if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr)
|
||||
write_thermistors = true;
|
||||
}
|
||||
|
||||
if (write_regs || write_thermistors) {
|
||||
if (!this->enter_cfg_update_())
|
||||
return LOG_STR("Entering config update mode failed");
|
||||
const LogString *error = this->write_config_(write_regs);
|
||||
// The chip stops measuring in config update mode, so leave it even after a failed write
|
||||
if (!this->exit_cfg_update_() && error == nullptr)
|
||||
error = LOG_STR("Exiting config update mode failed");
|
||||
if (error != nullptr)
|
||||
return error;
|
||||
if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled))
|
||||
return LOG_STR("Disabling regulators failed");
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp));
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const LogString *BQ76972Component::write_config_(bool write_regs) {
|
||||
if (write_regs) {
|
||||
const uint8_t zero = 0;
|
||||
if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) ||
|
||||
!this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1))
|
||||
return LOG_STR("Writing regulator config failed");
|
||||
}
|
||||
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
|
||||
if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr)
|
||||
continue;
|
||||
if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1))
|
||||
return LOG_STR("Writing thermistor pin config failed");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void BQ76972Component::setup() {
|
||||
if (const LogString *error = this->configure_(); error != nullptr)
|
||||
this->mark_failed(error);
|
||||
}
|
||||
|
||||
void BQ76972Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "BQ76972:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" CRC Mode: %s\n"
|
||||
" Regulators disabled: %s",
|
||||
this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_));
|
||||
}
|
||||
|
||||
void BQ76972Component::program_i2c_address(uint8_t address) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
const uint8_t shifted = address << 1;
|
||||
if (!this->enter_cfg_update_()) {
|
||||
this->status_set_warning(LOG_STR("Programming I2C address failed"));
|
||||
return;
|
||||
}
|
||||
// The new address only takes effect with the swap, so after a failed write the chip still answers at the
|
||||
// old one and must still be taken out of config update mode
|
||||
const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) &&
|
||||
this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0);
|
||||
if (written) {
|
||||
delay(10);
|
||||
this->set_i2c_address(address);
|
||||
}
|
||||
if (!this->exit_cfg_update_() || !written) {
|
||||
this->status_set_warning(LOG_STR("Programming I2C address failed"));
|
||||
return;
|
||||
}
|
||||
ESP_LOGI(TAG, "I2C address set to 0x%02X", address);
|
||||
}
|
||||
|
||||
template<size_t N> static bool any_set(const std::array<sensor::Sensor *, N> &sensors) {
|
||||
for (auto *sensor : sensors) {
|
||||
if (sensor != nullptr)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void BQ76972Component::update() {
|
||||
// Each group publishes as soon as its read succeeds, so one failed read does not hold back the others
|
||||
bool ok = true;
|
||||
uint16_t words[BQ76972_NUM_CELLS];
|
||||
if (this->stack_voltage_sensor_ != nullptr) {
|
||||
if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) {
|
||||
if (words[0] != 0)
|
||||
this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (any_set(this->cell_sensors_)) {
|
||||
if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) {
|
||||
for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) {
|
||||
if (this->cell_sensors_[i] != nullptr && words[i] != 0)
|
||||
this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (any_set(this->temperature_sensors_)) {
|
||||
if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) {
|
||||
for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) {
|
||||
if (this->temperature_sensors_[i] != nullptr)
|
||||
this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i]));
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
this->status_clear_warning();
|
||||
} else {
|
||||
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::bq76972
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::bq76972 {
|
||||
|
||||
static constexpr size_t BQ76972_NUM_CELLS = 16;
|
||||
static constexpr size_t BQ76972_NUM_TEMPERATURES = 10;
|
||||
|
||||
class BQ76972Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; }
|
||||
void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; }
|
||||
void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; }
|
||||
void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; }
|
||||
void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; }
|
||||
|
||||
void program_i2c_address(uint8_t address);
|
||||
|
||||
protected:
|
||||
bool read_block_(uint8_t start_register, uint8_t *data, size_t len);
|
||||
bool write_block_(uint8_t start_register, const uint8_t *data, size_t len);
|
||||
bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words);
|
||||
bool send_subcommand_(uint16_t subcommand);
|
||||
bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len);
|
||||
bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len);
|
||||
bool wait_for_subcommand_();
|
||||
bool wait_for_cfgupdate_();
|
||||
bool enter_cfg_update_();
|
||||
bool exit_cfg_update_();
|
||||
bool regs_disabled_(bool &out);
|
||||
const LogString *configure_();
|
||||
const LogString *write_config_(bool write_regs);
|
||||
|
||||
bool reg_disable_{false};
|
||||
bool crc_mode_{false};
|
||||
|
||||
sensor::Sensor *stack_voltage_sensor_{nullptr};
|
||||
std::array<sensor::Sensor *, BQ76972_NUM_CELLS> cell_sensors_{};
|
||||
std::array<sensor::Sensor *, BQ76972_NUM_TEMPERATURES> temperature_sensors_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::bq76972
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace esphome::chsc6x {
|
||||
|
||||
static const char *const TAG = "chsc6x.touchscreen";
|
||||
ESPHOME_LOG_TAG(TAG, "chsc6x.touchscreen");
|
||||
|
||||
static const uint8_t CHSC6X_REG_STATUS = 0x00;
|
||||
static const uint8_t CHSC6X_REG_STATUS_TOUCH = 0x00;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace esphome::cst816 {
|
||||
|
||||
static const char *const TAG = "cst816.touchscreen";
|
||||
ESPHOME_LOG_TAG(TAG, "cst816.touchscreen");
|
||||
|
||||
static const uint8_t REG_STATUS = 0x00;
|
||||
static const uint8_t REG_TOUCH_NUM = 0x02;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace esphome::cst9220 {
|
||||
|
||||
static const char *const TAG = "cst9220.touchscreen";
|
||||
ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen");
|
||||
|
||||
// The CST92xx family uses 16-bit (big-endian) register addresses.
|
||||
static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report
|
||||
|
||||
@@ -143,6 +143,8 @@ def validate_wakeup_pin(
|
||||
processed_pins: list[ConfigType] = [{CONF_PIN: value}]
|
||||
else:
|
||||
processed_pins = list(value)
|
||||
if not processed_pins:
|
||||
raise cv.Invalid("'wakeup_pin' needs at least one entry")
|
||||
|
||||
for i, pin_config in enumerate(processed_pins):
|
||||
# now validate each item
|
||||
@@ -154,29 +156,34 @@ def validate_wakeup_pin(
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
# right now only BK72XX supports the list format for wakeup pins
|
||||
if CORE.is_bk72xx:
|
||||
if CONF_WAKEUP_PIN_MODE in config:
|
||||
wakeup_pins = config.get(CONF_WAKEUP_PIN, [])
|
||||
if len(wakeup_pins) > 1:
|
||||
raise cv.Invalid(
|
||||
"You need to remove the global wakeup_pin_mode and define it per pin"
|
||||
)
|
||||
if wakeup_pins:
|
||||
if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]:
|
||||
raise cv.Invalid(
|
||||
"Specify wakeup_pin_mode either at the top level under deep_sleep "
|
||||
"or under the pin entry, not both"
|
||||
)
|
||||
wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
|
||||
elif (
|
||||
isinstance(config.get(CONF_WAKEUP_PIN), list)
|
||||
and len(config[CONF_WAKEUP_PIN]) > 1
|
||||
):
|
||||
wakeup_pins = config.get(CONF_WAKEUP_PIN)
|
||||
has_pin_mode = CONF_WAKEUP_PIN_MODE in config
|
||||
if has_pin_mode and wakeup_pins is None:
|
||||
raise cv.Invalid(
|
||||
"Your platform does not support providing multiple entries in wakeup_pin"
|
||||
"'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE]
|
||||
)
|
||||
|
||||
multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1
|
||||
# wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list
|
||||
if not CORE.is_bk72xx:
|
||||
if multiple_pins:
|
||||
raise cv.Invalid(
|
||||
"ESP32 does not support multiple entries for 'wakeup_pin'",
|
||||
path=[CONF_WAKEUP_PIN],
|
||||
)
|
||||
elif has_pin_mode:
|
||||
if multiple_pins:
|
||||
raise cv.Invalid(
|
||||
"Remove the global 'wakeup_pin_mode' and define it per pin",
|
||||
path=[CONF_WAKEUP_PIN_MODE],
|
||||
)
|
||||
if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]):
|
||||
raise cv.Invalid(
|
||||
"Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' "
|
||||
"or under the pin entry, not both",
|
||||
path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE],
|
||||
)
|
||||
# Move the global mode into the single pin entry, which to_code() reads
|
||||
pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
|
||||
return config
|
||||
|
||||
|
||||
@@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod:
|
||||
if CORE.is_bk72xx:
|
||||
max_duration = core.TimePeriod(hours=36)
|
||||
if value > max_duration:
|
||||
raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX")
|
||||
raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX")
|
||||
elif CORE.using_zephyr:
|
||||
max_duration = core.TimePeriod(days=49)
|
||||
if value > max_duration:
|
||||
raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr")
|
||||
raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr")
|
||||
return value
|
||||
|
||||
|
||||
@@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
_validate_sleep_duration,
|
||||
),
|
||||
cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin,
|
||||
cv.Optional(CONF_WAKEUP_PIN): cv.All(
|
||||
cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]),
|
||||
validate_wakeup_pin,
|
||||
),
|
||||
cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All(
|
||||
cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]),
|
||||
cv.enum(WAKEUP_PIN_MODES, upper=True),
|
||||
@@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if CONF_SLEEP_DURATION in config:
|
||||
cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION]))
|
||||
if CONF_WAKEUP_PIN in config:
|
||||
pins_as_list = config.get(CONF_WAKEUP_PIN, [])
|
||||
if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None:
|
||||
if CORE.is_bk72xx:
|
||||
cg.add(var.init_wakeup_pins(len(pins_as_list)))
|
||||
for item in pins_as_list:
|
||||
cg.add(var.init_wakeup_pins(len(wakeup_pins)))
|
||||
for item in wakeup_pins:
|
||||
cg.add(
|
||||
var.add_wakeup_pin(
|
||||
await cg.gpio_pin_expression(item[CONF_PIN]),
|
||||
@@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
)
|
||||
else:
|
||||
pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN])
|
||||
pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN])
|
||||
cg.add(var.set_wakeup_pin(pin))
|
||||
if CONF_WAKEUP_PIN_MODE in config:
|
||||
cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE]))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include "esphome/core/color.h"
|
||||
|
||||
namespace esphome::display {
|
||||
@@ -154,4 +156,54 @@ class ColorUtil {
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */
|
||||
static constexpr uint16_t REC601_LUMA_R = 77;
|
||||
static constexpr uint16_t REC601_LUMA_G = 150;
|
||||
static constexpr uint16_t REC601_LUMA_B = 29;
|
||||
|
||||
/** Rec.601 luma of an RGB color, for optimum perceptual brightness */
|
||||
constexpr uint8_t rec601_luma(Color color) {
|
||||
return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8);
|
||||
}
|
||||
|
||||
/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */
|
||||
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
|
||||
|
||||
/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division
|
||||
* per channel.
|
||||
*/
|
||||
class Rgb565ToMono {
|
||||
public:
|
||||
explicit Rgb565ToMono(ColorOrder order) {
|
||||
// Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order
|
||||
uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B;
|
||||
if (order == COLOR_ORDER_BGR) {
|
||||
high = REC601_LUMA_B;
|
||||
low = REC601_LUMA_R;
|
||||
} else if (order == COLOR_ORDER_GRB) {
|
||||
high = REC601_LUMA_G;
|
||||
middle = REC601_LUMA_R;
|
||||
}
|
||||
// Each field value scaled to 0..255, then weighted
|
||||
for (unsigned i = 0; i != 32; i++) {
|
||||
this->high_[i] = high * esp_scale(i, 31);
|
||||
this->low_[i] = low * esp_scale(i, 31);
|
||||
}
|
||||
for (unsigned i = 0; i != 64; i++)
|
||||
this->middle_[i] = middle * esp_scale(i, 63);
|
||||
}
|
||||
|
||||
uint8_t operator()(uint16_t value) const {
|
||||
const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F];
|
||||
return ((weighted + 128u) >> 8) >= 128 ? 1 : 0;
|
||||
}
|
||||
|
||||
protected:
|
||||
// Weighted contribution of each value of each field
|
||||
std::array<uint16_t, 32> high_{};
|
||||
std::array<uint16_t, 64> middle_{};
|
||||
std::array<uint16_t, 32> low_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::display
|
||||
|
||||
@@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"]
|
||||
|
||||
emontx_ns = cg.esphome_ns.namespace("emontx")
|
||||
EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice)
|
||||
LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True)
|
||||
|
||||
CONF_EMONTX_ID = "emontx_id"
|
||||
CONF_TAG_NAME = "tag_name"
|
||||
CONF_ON_JSON = "on_json"
|
||||
CONF_LOCK_ON_REBOOT = "lock_on_reboot"
|
||||
LOCK_ON_REBOOT_AUTO = "auto"
|
||||
LOCK_ON_REBOOT_MODES = {
|
||||
LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO,
|
||||
True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS,
|
||||
False: LockOnReboot.LOCK_ON_REBOOT_NEVER,
|
||||
}
|
||||
|
||||
DOMAIN = "emontx"
|
||||
|
||||
@@ -59,7 +66,9 @@ CONFIG_SCHEMA = (
|
||||
cv.GenerateID(): cv.declare_id(EmonTx),
|
||||
cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(),
|
||||
cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(),
|
||||
cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean,
|
||||
cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any(
|
||||
cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None:
|
||||
await uart.register_uart_device(var, config)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
if config[CONF_LOCK_ON_REBOOT]:
|
||||
cg.add(var.set_lock_on_reboot(True))
|
||||
lock = config[CONF_LOCK_ON_REBOOT]
|
||||
cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock]))
|
||||
if lock == LOCK_ON_REBOOT_AUTO:
|
||||
# Locking depends on the firmware version the emonTx reports
|
||||
cg.add_define("USE_EMONTX_VERSION")
|
||||
|
||||
|
||||
# Action: emontx.send_command
|
||||
|
||||
@@ -1,23 +1,91 @@
|
||||
#include "emontx.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include "esphome/components/json/json_util.h"
|
||||
|
||||
namespace esphome::emontx {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "emontx");
|
||||
|
||||
static constexpr uint32_t UNLOCK_ID = 0;
|
||||
// The emonTx joins bytes received while a command is pending into that command, so the next one waits
|
||||
static constexpr uint32_t COMMAND_GAP_MS = 250;
|
||||
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
static constexpr uint32_t VERSION_REQUEST_ID = 1;
|
||||
// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all
|
||||
static constexpr uint8_t VERSION_RETRY_FRAMES = 2;
|
||||
static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3;
|
||||
// Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1"
|
||||
static const char VERSION_PREFIX[] PROGMEM = " - Version:";
|
||||
// Every data line starts with the message counter, in JSON or text format
|
||||
static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":";
|
||||
static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:";
|
||||
// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command
|
||||
static constexpr uint32_t LOCK_MIN_MAJOR = 1;
|
||||
static constexpr uint32_t LOCK_MIN_MINOR = 2;
|
||||
|
||||
// True if line starts with the PROGMEM prefix and has more after it
|
||||
static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) {
|
||||
return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0;
|
||||
}
|
||||
|
||||
// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none
|
||||
static bool read_number(const char *&pos, const char *end, uint32_t &value) {
|
||||
const char *start = pos;
|
||||
value = 0;
|
||||
while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9')
|
||||
value = value * 10 + (*pos++ - '0');
|
||||
return pos != start;
|
||||
}
|
||||
|
||||
// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on
|
||||
static bool supports_lock(const char *version, const char *end) {
|
||||
if (version < end && (*version == 'v' || *version == 'V'))
|
||||
version++;
|
||||
uint32_t major;
|
||||
if (!read_number(version, end, major) || version == end || *version != '.')
|
||||
return false;
|
||||
version++;
|
||||
uint32_t minor;
|
||||
if (!read_number(version, end, minor))
|
||||
return false;
|
||||
return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR);
|
||||
}
|
||||
#endif
|
||||
|
||||
void EmonTx::setup() {
|
||||
this->buffer_pos_ = 0;
|
||||
if (!this->lock_on_reboot_)
|
||||
if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) {
|
||||
// The line feed ends any boot noise
|
||||
this->write_byte('\n');
|
||||
this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() {
|
||||
this->unlock_();
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
if (this->wants_version_())
|
||||
this->request_version_();
|
||||
#endif
|
||||
});
|
||||
return;
|
||||
// The line feed ends any boot noise first
|
||||
ESP_LOGI(TAG, "Unlocking emonTx after boot");
|
||||
this->write_byte('\n');
|
||||
}
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
// With AUTO, the reply decides whether to unlock
|
||||
if (this->wants_version_())
|
||||
this->request_version_();
|
||||
#endif
|
||||
}
|
||||
|
||||
void EmonTx::unlock_() {
|
||||
ESP_LOGI(TAG, "Unlocking emonTx");
|
||||
this->send_command("emonunlock");
|
||||
}
|
||||
|
||||
void EmonTx::on_shutdown() {
|
||||
if (!this->lock_on_reboot_)
|
||||
bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS;
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_;
|
||||
#endif
|
||||
if (!lock)
|
||||
return;
|
||||
if (this->is_paused()) {
|
||||
ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused");
|
||||
@@ -48,6 +116,11 @@ void EmonTx::loop() {
|
||||
uint8_t dropped;
|
||||
while (this->available() > 0 && this->read_byte(&dropped))
|
||||
this->discarding_line_ = dropped != '\n';
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
// The emonTx may have been flashed, or a request dropped, during the pause
|
||||
if (this->wants_version_())
|
||||
this->request_version_();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Read all available data to prevent UART buffer overflow
|
||||
@@ -77,6 +150,12 @@ void EmonTx::loop() {
|
||||
ESP_LOGV(TAG, "Line is JSON, parsing...");
|
||||
this->parse_json_(this->buffer_.data(), len);
|
||||
}
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
if (this->wants_version_()) {
|
||||
this->parse_msg_number_(this->buffer_.data(), len);
|
||||
this->parse_version_line_(this->buffer_.data(), len);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} else if (received == '\r' || this->discarding_line_) {
|
||||
// Ignore CR, and the rest of a line that overflowed or was interrupted by a pause
|
||||
@@ -112,12 +191,91 @@ void EmonTx::parse_json_(const char *data, size_t len) {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
void EmonTx::request_version_() {
|
||||
// The firmware may have changed; until the reply, an unknown version gets no lock commands
|
||||
this->lock_supported_ = false;
|
||||
this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS;
|
||||
this->send_version_request_();
|
||||
}
|
||||
|
||||
// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_()
|
||||
void EmonTx::send_version_request_() {
|
||||
if (this->is_paused())
|
||||
return;
|
||||
this->write_byte('\n');
|
||||
this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); });
|
||||
}
|
||||
|
||||
void EmonTx::parse_msg_number_(const char *line, size_t len) {
|
||||
const char *pos;
|
||||
if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) {
|
||||
pos = line + sizeof(MSG_JSON_PREFIX) - 1;
|
||||
} else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) {
|
||||
pos = line + sizeof(MSG_TEXT_PREFIX) - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
uint32_t msg_number;
|
||||
if (!read_number(pos, line + len, msg_number))
|
||||
return;
|
||||
|
||||
// MSG restarts at 1 when the emonTx reboots, as it does after a firmware update
|
||||
if (msg_number <= this->last_msg_number_) {
|
||||
this->request_version_();
|
||||
} else if (this->version_frames_left_ > 0) {
|
||||
if (--this->version_frames_left_ == 0) {
|
||||
ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked");
|
||||
} else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) {
|
||||
this->send_version_request_();
|
||||
}
|
||||
}
|
||||
this->last_msg_number_ = msg_number;
|
||||
}
|
||||
|
||||
void EmonTx::parse_version_line_(const char *line, size_t len) {
|
||||
constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1;
|
||||
if (!starts_with(line, len, VERSION_PREFIX, prefix_len))
|
||||
return;
|
||||
const char *version = line + prefix_len;
|
||||
const char *end = line + len;
|
||||
while (version < end && *version == ' ')
|
||||
version++;
|
||||
if (version == end)
|
||||
return;
|
||||
ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast<int>(end - version), version);
|
||||
this->version_frames_left_ = 0;
|
||||
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
|
||||
if (this->version_text_sensor_ != nullptr)
|
||||
this->version_text_sensor_->publish_state(version, end - version);
|
||||
#endif
|
||||
|
||||
bool supported = supports_lock(version, end);
|
||||
if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) {
|
||||
// Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries
|
||||
this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() {
|
||||
if (this->is_paused())
|
||||
return;
|
||||
this->unlock_();
|
||||
this->unlock_sent_ = true;
|
||||
});
|
||||
}
|
||||
this->lock_supported_ = supported;
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Logs the EmonTx component configuration details.
|
||||
*/
|
||||
void EmonTx::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "EmonTx:");
|
||||
ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_));
|
||||
const char *lock = LOG_STR_LITERAL("no");
|
||||
if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) {
|
||||
lock = LOG_STR_LITERAL("yes");
|
||||
} else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) {
|
||||
lock = LOG_STR_LITERAL("auto");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock);
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size());
|
||||
@@ -127,6 +285,9 @@ void EmonTx::dump_config() {
|
||||
#else
|
||||
ESP_LOGCONFIG(TAG, " Sensor support: DISABLED");
|
||||
#endif
|
||||
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
|
||||
LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,12 +13,21 @@
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::emontx {
|
||||
|
||||
/// Maximum line length in bytes (plus one byte reserved for null terminator)
|
||||
static constexpr size_t MAX_LINE_LENGTH = 1024;
|
||||
|
||||
enum class LockOnReboot : uint8_t {
|
||||
LOCK_ON_REBOOT_NEVER,
|
||||
LOCK_ON_REBOOT_ALWAYS,
|
||||
LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later
|
||||
};
|
||||
|
||||
/**
|
||||
* @class EmonTx
|
||||
* @brief Main class for the EmonTx component.
|
||||
@@ -27,6 +36,9 @@ static constexpr size_t MAX_LINE_LENGTH = 1024;
|
||||
* extracts tags and values, and publishes them to registered sensors.
|
||||
*/
|
||||
class EmonTx final : public Component, public uart::UARTDevice {
|
||||
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
|
||||
SUB_TEXT_SENSOR(version)
|
||||
#endif
|
||||
public:
|
||||
EmonTx() = default;
|
||||
|
||||
@@ -50,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice {
|
||||
bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); }
|
||||
|
||||
/// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken
|
||||
/// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later.
|
||||
void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; }
|
||||
/// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks.
|
||||
void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; }
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
void init_sensors(size_t count) { this->sensors_.init(count); }
|
||||
@@ -60,6 +72,20 @@ class EmonTx final : public Component, public uart::UARTDevice {
|
||||
|
||||
protected:
|
||||
void parse_json_(const char *data, size_t len);
|
||||
void unlock_();
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
bool wants_version_() const {
|
||||
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
|
||||
if (this->version_text_sensor_ != nullptr)
|
||||
return true;
|
||||
#endif
|
||||
return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO;
|
||||
}
|
||||
void request_version_();
|
||||
void send_version_request_();
|
||||
void parse_msg_number_(const char *line, size_t len);
|
||||
void parse_version_line_(const char *line, size_t len);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
FixedVector<std::pair<const char *, sensor::Sensor *>> sensors_{};
|
||||
@@ -69,8 +95,14 @@ class EmonTx final : public Component, public uart::UARTDevice {
|
||||
uint16_t buffer_pos_{0};
|
||||
std::atomic<bool> paused_{false};
|
||||
bool was_paused_{false};
|
||||
bool lock_on_reboot_{false};
|
||||
LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER};
|
||||
bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line
|
||||
#ifdef USE_EMONTX_VERSION
|
||||
bool lock_supported_{false}; // The last version reported is v1.2.0 or later
|
||||
bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot
|
||||
uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed
|
||||
uint32_t last_msg_number_{0};
|
||||
#endif
|
||||
std::array<char, MAX_LINE_LENGTH + 1> buffer_{};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_EMONTX_ID, EmonTx
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx),
|
||||
cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema(
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_EMONTX_ID])
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None:
|
||||
cg.add_define("USE_EMONTX_VERSION")
|
||||
cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR")
|
||||
@@ -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:
|
||||
@@ -242,6 +258,7 @@ void EPaperBase::process_state_() {
|
||||
break;
|
||||
case EPaperState::DEEP_SLEEP:
|
||||
this->deep_sleep();
|
||||
this->status_clear_warning();
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
|
||||
break;
|
||||
@@ -345,6 +362,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) {
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies
|
||||
// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step
|
||||
// rows.
|
||||
template<bool SwapAxes, bool BigEndianSource>
|
||||
void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count,
|
||||
const Rgb565ToMono &to_mono) {
|
||||
const ptrdiff_t byte_step = SwapAxes ? step * static_cast<ptrdiff_t>(row_width) : 0;
|
||||
for (; count != 0; count--, source += 2) {
|
||||
const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8);
|
||||
if (to_mono(value)) {
|
||||
*byte |= mask;
|
||||
} else {
|
||||
*byte &= ~mask;
|
||||
}
|
||||
if constexpr (SwapAxes) {
|
||||
byte += byte_step;
|
||||
} else if (step > 0) {
|
||||
mask >>= 1;
|
||||
if (mask == 0) {
|
||||
mask = 0x80;
|
||||
byte++;
|
||||
}
|
||||
} else {
|
||||
mask <<= 1;
|
||||
if (mask == 0) {
|
||||
mask = 0x01;
|
||||
byte--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so
|
||||
// the visible part of a block and its dirty area are worked out once for the whole block.
|
||||
void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr,
|
||||
ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) {
|
||||
const bool swap = this->effective_transform_ & SWAP_XY;
|
||||
const bool mirror_x = this->effective_transform_ & MIRROR_X;
|
||||
const bool mirror_y = this->effective_transform_ & MIRROR_Y;
|
||||
// The buffer axis a source column moves along, and the one a source row moves along
|
||||
const int column_limit = swap ? this->height_ : this->width_;
|
||||
const int row_limit = swap ? this->width_ : this->height_;
|
||||
const bool column_mirrored = swap ? mirror_y : mirror_x;
|
||||
const bool row_mirrored = swap ? mirror_x : mirror_y;
|
||||
const int first_column = std::max(0, -x_start);
|
||||
const int end_column = std::min(w, column_limit - x_start);
|
||||
const int first_row = std::max(0, -y_start);
|
||||
const int end_row = std::min(h, row_limit - y_start);
|
||||
if (first_column >= end_column || first_row >= end_row)
|
||||
return;
|
||||
auto column_to_buffer = [&](int column) {
|
||||
const int u = x_start + column;
|
||||
return column_mirrored ? column_limit - 1 - u : u;
|
||||
};
|
||||
auto row_to_buffer = [&](int row) {
|
||||
const int v = y_start + row;
|
||||
return row_mirrored ? row_limit - 1 - v : v;
|
||||
};
|
||||
|
||||
const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1));
|
||||
const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1));
|
||||
const int columns = end_column - first_column;
|
||||
const int rows = end_row - first_row;
|
||||
const int x_low = swap ? row_low : column_low;
|
||||
const int y_low = swap ? column_low : row_low;
|
||||
const int x_high = x_low + (swap ? rows : columns);
|
||||
const int y_high = y_low + (swap ? columns : rows);
|
||||
|
||||
const Rgb565ToMono to_mono(order);
|
||||
const size_t line_stride = x_offset + w + x_pad;
|
||||
const size_t row_width = this->row_width_;
|
||||
const int step = column_mirrored ? -1 : 1;
|
||||
for (int row = first_row; row != end_row; row++) {
|
||||
const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2;
|
||||
const int along = column_to_buffer(first_column);
|
||||
const int across = row_to_buffer(row);
|
||||
const int x = swap ? across : along;
|
||||
const int y = swap ? along : across;
|
||||
uint8_t *byte = buffer + y * row_width + x / 8;
|
||||
const uint8_t mask = 0x80u >> (x % 8);
|
||||
if (swap) {
|
||||
if (big_endian) {
|
||||
write_rgb565_row<true, true>(byte, mask, step, row_width, source, columns, to_mono);
|
||||
} else {
|
||||
write_rgb565_row<true, false>(byte, mask, step, row_width, source, columns, to_mono);
|
||||
}
|
||||
} else if (big_endian) {
|
||||
write_rgb565_row<false, true>(byte, mask, step, row_width, source, columns, to_mono);
|
||||
} else {
|
||||
write_rgb565_row<false, false>(byte, mask, step, row_width, source, columns, to_mono);
|
||||
}
|
||||
}
|
||||
this->x_low_ = clamp_at_most(this->x_low_, x_low);
|
||||
this->x_high_ = clamp_at_least(this->x_high_, x_high);
|
||||
this->y_low_ = clamp_at_most(this->y_low_, y_low);
|
||||
this->y_high_ = clamp_at_least(this->y_high_, y_high);
|
||||
}
|
||||
|
||||
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
|
||||
// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at().
|
||||
void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
|
||||
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
|
||||
size_t contiguous_length;
|
||||
uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length);
|
||||
if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 ||
|
||||
contiguous_length != this->buffer_length_) {
|
||||
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
|
||||
return;
|
||||
}
|
||||
this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad);
|
||||
}
|
||||
|
||||
void EPaperBase::dump_config() {
|
||||
LOG_DISPLAY("", "E-Paper SPI", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
|
||||
@@ -107,6 +107,8 @@ class EPaperBase : public Display,
|
||||
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
|
||||
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
|
||||
void draw_pixel_at(int x, int y, Color color) override;
|
||||
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
|
||||
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
|
||||
// Make the next update a full one. Applied when that update starts, so one in progress is not affected.
|
||||
void request_full_update() { this->full_update_requested_ = true; }
|
||||
// True from the start of an update until the display has refreshed and gone back to sleep.
|
||||
@@ -125,9 +127,14 @@ class EPaperBase : public Display,
|
||||
virtual bool initialise(bool partial);
|
||||
void send_init_sequence_(const uint8_t *sequence, size_t length);
|
||||
void wait_for_idle_(bool should_wait);
|
||||
void abandon_update_();
|
||||
bool init_buffer_(size_t buffer_length);
|
||||
void update_effective_transform_();
|
||||
bool rotate_coordinates_(int &x, int &y);
|
||||
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
|
||||
bool big_endian, int x_offset, int y_offset, int x_pad);
|
||||
// Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly
|
||||
virtual bool buffer_is_mono() const { return false; }
|
||||
|
||||
/**
|
||||
* Methods that must be implemented by concrete classes to control the display
|
||||
@@ -137,6 +144,8 @@ class EPaperBase : public Display,
|
||||
* @return true if done, false if it should be called next loop
|
||||
*/
|
||||
virtual bool transfer_data() = 0;
|
||||
// Called when an update is abandoned in the middle of transfer_data(), to release anything it left open
|
||||
virtual void abort_transfer() {}
|
||||
/**
|
||||
* Refresh the screen after data transfer
|
||||
*/
|
||||
@@ -187,18 +196,20 @@ class EPaperBase : public Display,
|
||||
// point to the pixel past the last one updated, i.e. may range up to width/height.
|
||||
uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{};
|
||||
|
||||
uint32_t waiting_for_idle_start_{};
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
uint32_t waiting_for_idle_last_print_{};
|
||||
uint32_t waiting_for_idle_start_{};
|
||||
#endif
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
|
||||
uint32_t update_start_time_{};
|
||||
#endif
|
||||
|
||||
// properties with specific initialisers go last
|
||||
EPaperState state_{EPaperState::IDLE};
|
||||
uint32_t reset_duration_{10};
|
||||
// Longer than any panel takes to refresh; past it the busy pin is assumed stuck
|
||||
uint16_t busy_timeout_seconds_{120};
|
||||
uint8_t full_update_every_{1};
|
||||
EPaperState state_{EPaperState::IDLE};
|
||||
};
|
||||
|
||||
} // namespace esphome::epaper_spi
|
||||
|
||||
@@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase {
|
||||
}
|
||||
|
||||
protected:
|
||||
bool buffer_is_mono() const override { return true; }
|
||||
void refresh_screen(bool partial) override;
|
||||
void power_on() override {}
|
||||
void power_off() override{};
|
||||
|
||||
@@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() {
|
||||
// A full update ignores 0x26, and the copy may not hold a real frame yet (first update after
|
||||
// boot): send the new frame to both planes.
|
||||
const bool send_copy = this->plane_ == 0 && this->update_count_ != 0;
|
||||
const bool direct = send_copy || this->buffer_is_plane();
|
||||
// A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper
|
||||
// buffer (the four-level model) returns false and packs the plane in plane_row().
|
||||
const bool direct = send_copy || this->buffer_is_mono();
|
||||
const auto &source = send_copy ? this->sent_ : this->buffer_;
|
||||
const size_t row_length = this->plane_row_length_();
|
||||
const size_t plane_length = row_length * this->height_;
|
||||
|
||||
@@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono {
|
||||
void init_comparison_frame_();
|
||||
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
|
||||
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
|
||||
// Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is.
|
||||
// A subclass with a deeper buffer returns false and overrides plane_row().
|
||||
virtual bool buffer_is_plane() const { return true; }
|
||||
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
|
||||
virtual void plane_row(size_t y, uint8_t *out);
|
||||
bool reset() override;
|
||||
|
||||
@@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 {
|
||||
void draw_pixel_at(int x, int y, Color color) override;
|
||||
|
||||
protected:
|
||||
bool buffer_is_mono() const override { return false; }
|
||||
// A partial update if partial updates are enabled and this is not a full one.
|
||||
bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); }
|
||||
bool buffer_is_plane() const override { return false; }
|
||||
void plane_row(size_t y, uint8_t *out) override;
|
||||
void refresh_screen(bool partial) override;
|
||||
bool transfer_data() override;
|
||||
|
||||
@@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void EPaperT133A01::abort_transfer() {
|
||||
// A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows
|
||||
if (this->current_data_index_ == 0)
|
||||
return;
|
||||
this->disable();
|
||||
this->cs_pin_->digital_write(true);
|
||||
this->cs1_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
void EPaperT133A01::dump_config() {
|
||||
EPaperBase::dump_config();
|
||||
LOG_PIN(" CS Pin: ", this->cs_pin_);
|
||||
|
||||
@@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase {
|
||||
void deep_sleep() override;
|
||||
|
||||
bool transfer_data() override;
|
||||
void abort_transfer() override;
|
||||
|
||||
/**
|
||||
* Send a command (and optional data) selecting one or both controllers.
|
||||
|
||||
@@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase {
|
||||
}
|
||||
|
||||
protected:
|
||||
bool buffer_is_mono() const override { return true; }
|
||||
bool initialise(bool partial) override;
|
||||
bool transfer_data() override;
|
||||
void refresh_screen(bool partial) override;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -282,7 +282,7 @@ void ESP32Camera::loop() {
|
||||
}
|
||||
this->frame_count_++;
|
||||
if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) {
|
||||
ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000);
|
||||
ESP_LOGD(TAG, "Received %u images in last %" PRIu32 "s", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000);
|
||||
this->last_log_time_ = now;
|
||||
this->frame_count_ = 0;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <deque>
|
||||
|
||||
namespace esphome::ezo {
|
||||
|
||||
static const char *const TAG = "ezo.sensor";
|
||||
ESPHOME_LOG_TAG(TAG, "ezo.sensor");
|
||||
|
||||
enum EzoCommandType : uint8_t {
|
||||
EZO_READ = 0,
|
||||
|
||||
@@ -330,7 +330,9 @@ async def write_image(
|
||||
if byte_order is not None:
|
||||
# Check for valid type has already been done in validate_settings
|
||||
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
|
||||
pixels = encoder.convert(image.resize((width, height)), path).getdata()
|
||||
pixels = encoder.convert(
|
||||
image.resize((width, height)), path
|
||||
).get_flattened_data()
|
||||
for row in range(height):
|
||||
for col in range(width):
|
||||
encoder.encode(pixels[row * width + col])
|
||||
|
||||
@@ -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 @@
|
||||
DOMAIN = "friedrich"
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MODEL, CONF_SENSOR
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
CODEOWNERS = ["@rwrozelle"]
|
||||
|
||||
friedrich_ns = cg.esphome_ns.namespace("friedrich")
|
||||
FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR)
|
||||
|
||||
Model = friedrich_ns.enum("Model", is_class=True)
|
||||
MODELS = {
|
||||
"MW12Y3H": Model.MODEL_MW12Y3H,
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend(
|
||||
{
|
||||
cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True),
|
||||
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
|
||||
cv.Optional(CONF_SENSOR): cv.invalid(
|
||||
"'sensor' is not supported by the Friedrich climate component"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
remote_base.request_protocol("aeha") # used from C++
|
||||
var = await climate_ir.new_climate_ir(config)
|
||||
cg.add(var.set_model(config[CONF_MODEL]))
|
||||
@@ -0,0 +1,234 @@
|
||||
#include "friedrich.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/components/remote_base/aeha_protocol.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::friedrich {
|
||||
// clang-format off
|
||||
/*
|
||||
* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation.
|
||||
* AEHA
|
||||
* Byte0 Fixed = 0x00
|
||||
* Byte1 Fixed = 0x08
|
||||
* Byte2 Fixed = 0x08
|
||||
* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F
|
||||
* Byte4 Checksum when command, else Fixed = 0x90
|
||||
* Byte5 N/A when command, else Fixed = 0x0C
|
||||
* Byte6 N/A when command, else Temp(F), add 0x80 for On
|
||||
* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed)
|
||||
* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing
|
||||
* Byte9 N/A when command, else Fixed = 0x00 // not interested
|
||||
* Byte10 N/A when command, else Fixed = 0x00 // not interested
|
||||
* Byte11 N/A when command, else Fixed = 0x00 // not interested
|
||||
* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04)
|
||||
* Byte13 N/A when command, else CheckSum
|
||||
*/
|
||||
// clang-format on
|
||||
ESPHOME_LOG_TAG(TAG, "friedrich");
|
||||
|
||||
static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64;
|
||||
static constexpr uint8_t TEMP_DEFAULT = 72;
|
||||
static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH;
|
||||
static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5;
|
||||
static constexpr uint16_t CARRIER_ADDRESS = 0x28C6;
|
||||
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
|
||||
|
||||
static constexpr uint8_t BYTE3_POWER_OFF = 0x40;
|
||||
static constexpr uint8_t BYTE6_POWER_ON = 0x80;
|
||||
static constexpr uint8_t BYTE7_MODE_AUTO = 0x00;
|
||||
static constexpr uint8_t BYTE8_FAN_AUTO = 0x00;
|
||||
static constexpr uint8_t BYTE8_FAN_SWING = 0x08;
|
||||
|
||||
// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state
|
||||
static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = {
|
||||
0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00};
|
||||
// The off command, including its checksum (255 - byte 3)
|
||||
static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF,
|
||||
255 - BYTE3_POWER_OFF};
|
||||
|
||||
struct Code {
|
||||
uint8_t value; // a climate enum, all of which are uint8_t
|
||||
uint8_t code;
|
||||
};
|
||||
static constexpr Code MODES[] PROGMEM = {
|
||||
{climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO},
|
||||
{climate::CLIMATE_MODE_COOL, 0x80},
|
||||
{climate::CLIMATE_MODE_DRY, 0x40},
|
||||
{climate::CLIMATE_MODE_FAN_ONLY, 0xC0},
|
||||
{climate::CLIMATE_MODE_HEAT, 0x20},
|
||||
};
|
||||
static constexpr Code FANS[] PROGMEM = {
|
||||
{climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80},
|
||||
{climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0},
|
||||
{climate::CLIMATE_FAN_QUIET, 0x20},
|
||||
};
|
||||
|
||||
// The entry whose value (or code) matches key. Entries are copied out of PROGMEM.
|
||||
template<size_t N> static optional<Code> find_entry(const Code (&table)[N], uint8_t key, bool match_code) {
|
||||
for (const Code &slot : table) {
|
||||
Code entry;
|
||||
progmem_memcpy(&entry, &slot, sizeof(entry));
|
||||
if ((match_code ? entry.code : entry.value) == key)
|
||||
return entry;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse.
|
||||
static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; }
|
||||
|
||||
static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) {
|
||||
for (uint8_t i = from; i <= to; i++) {
|
||||
if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* https://gist.github.com/GeorgeDewar/11171561
|
||||
* 1. Reverse (flip) bytes 6 - 13)
|
||||
* 2. Sum those bytes
|
||||
* 3. (208 - sum) % 256
|
||||
* 4. Reverse (flip) bytes of result
|
||||
*/
|
||||
static uint8_t checksum_state(const uint8_t *data) {
|
||||
uint8_t sum = 0;
|
||||
for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) {
|
||||
sum += reverse_bits(data[i]);
|
||||
}
|
||||
return reverse_bits(static_cast<uint8_t>(208 - sum));
|
||||
}
|
||||
|
||||
static const LogString *model_to_string(Model model) {
|
||||
switch (model) {
|
||||
case Model::MODEL_MW12Y3H:
|
||||
default:
|
||||
return LOG_STR("MW12Y3H");
|
||||
}
|
||||
}
|
||||
|
||||
climate::ClimateTraits FriedrichClimate::traits() {
|
||||
auto traits = ClimateIR::traits();
|
||||
traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT);
|
||||
return traits;
|
||||
}
|
||||
|
||||
void FriedrichClimate::setup() {
|
||||
ClimateIR::setup();
|
||||
// The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version.
|
||||
if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) {
|
||||
this->target_temperature = TEMP_DEFAULT;
|
||||
}
|
||||
this->target_temperature = std::min<float>(this->target_temperature, TEMP_MAX);
|
||||
}
|
||||
|
||||
void FriedrichClimate::dump_config() {
|
||||
// ClimateIR::dump_config() is not called: it prints the temperature range in Celsius
|
||||
LOG_CLIMATE("", "Friedrich Climate", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Temperature range: %u to %u °F\n"
|
||||
" Model: %s",
|
||||
TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_)));
|
||||
}
|
||||
|
||||
void FriedrichClimate::transmit_state() {
|
||||
if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
this->transmit_off_();
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Transmit state");
|
||||
|
||||
// Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it,
|
||||
// so the state published after this call matches the frame sent.
|
||||
const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT;
|
||||
uint8_t temperature = (uint8_t) roundf(clamp<float>(this->target_temperature, effective_min, TEMP_MAX));
|
||||
temperature += temperature % 2;
|
||||
this->target_temperature = temperature;
|
||||
|
||||
const auto mode = find_entry(MODES, this->mode, false);
|
||||
const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false);
|
||||
|
||||
uint8_t frame[STATE_MESSAGE_LENGTH];
|
||||
progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame));
|
||||
frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP);
|
||||
frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO;
|
||||
frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO;
|
||||
if (this->swing_mode != climate::CLIMATE_SWING_OFF)
|
||||
frame[8] |= BYTE8_FAN_SWING;
|
||||
frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame);
|
||||
|
||||
this->transmit_(frame, sizeof(frame));
|
||||
}
|
||||
|
||||
void FriedrichClimate::transmit_off_() {
|
||||
ESP_LOGV(TAG, "Transmit off");
|
||||
|
||||
uint8_t frame[STATE_MESSAGE_LENGTH_UTIL];
|
||||
progmem_memcpy(frame, OFF_FRAME, sizeof(frame));
|
||||
this->transmit_(frame, sizeof(frame));
|
||||
}
|
||||
|
||||
void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) {
|
||||
ESP_LOGV(TAG, "Transmit message");
|
||||
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
auto *dst = transmit.get_data();
|
||||
dst->set_carrier_frequency(CARRIER_FREQUENCY);
|
||||
remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len);
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) {
|
||||
ESP_LOGV(TAG, "Received message");
|
||||
const auto odata = remote_base::AEHAProtocol().decode(src);
|
||||
if (!odata.has_value())
|
||||
return false;
|
||||
const auto &d = odata->data;
|
||||
// Other vendors also use AEHA; only accept frames with our address and fixed header bytes.
|
||||
if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2))
|
||||
return false;
|
||||
|
||||
if (d.size() == STATE_MESSAGE_LENGTH_UTIL) {
|
||||
// Not looking for other types of messages
|
||||
if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3])
|
||||
return false;
|
||||
ESP_LOGV(TAG, "Received off message");
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) ||
|
||||
!matches_template(d.data(), 3, 5))
|
||||
return false;
|
||||
|
||||
// Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY)
|
||||
const auto mode = find_entry(MODES, d[7], true);
|
||||
if (!mode.has_value()) {
|
||||
ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]);
|
||||
return false;
|
||||
}
|
||||
this->mode = static_cast<climate::ClimateMode>(mode->value);
|
||||
|
||||
// State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is
|
||||
// stripped rather than acted on
|
||||
const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON;
|
||||
const uint8_t temp_index = reverse_nibble(temp_code);
|
||||
if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP)
|
||||
this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP;
|
||||
|
||||
this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
|
||||
const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true);
|
||||
this->fan_mode = fan.has_value() ? static_cast<climate::ClimateFanMode>(fan->value) : climate::CLIMATE_FAN_AUTO;
|
||||
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::friedrich
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
|
||||
namespace esphome::friedrich {
|
||||
|
||||
// Extension point for other units: add new models here and branch on model_ where they differ.
|
||||
// The default must stay MW12Y3H so existing configurations do not change.
|
||||
enum class Model : uint8_t {
|
||||
MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit
|
||||
};
|
||||
|
||||
class FriedrichClimate : public climate_ir::ClimateIR {
|
||||
public:
|
||||
// The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values.
|
||||
static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating
|
||||
static constexpr uint8_t TEMP_MAX = 88;
|
||||
static constexpr uint8_t TEMP_STEP = 2;
|
||||
static constexpr uint8_t STATE_MESSAGE_LENGTH = 14;
|
||||
|
||||
FriedrichClimate()
|
||||
: ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true,
|
||||
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM,
|
||||
climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET},
|
||||
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {}
|
||||
|
||||
void set_model(Model model) { this->model_ = model; }
|
||||
|
||||
protected:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
climate::ClimateTraits traits() override;
|
||||
/// Transmit via IR the state of this climate controller.
|
||||
void transmit_state() override;
|
||||
/// Transmit via IR power off command.
|
||||
void transmit_off_();
|
||||
|
||||
/// Parse incoming message
|
||||
bool on_receive(remote_base::RemoteReceiveData src) override;
|
||||
|
||||
/// Transmit message as IR pulses
|
||||
void transmit_(const uint8_t *data, uint8_t len);
|
||||
|
||||
Model model_{Model::MODEL_MW12Y3H};
|
||||
};
|
||||
|
||||
} // namespace esphome::friedrich
|
||||
@@ -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))
|
||||
@@ -2,12 +2,13 @@
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#include "esphome/components/display/display_buffer.h"
|
||||
#include "esphome/components/display/display_color_utils.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "ili9xxx_defines.h"
|
||||
#include "ili9xxx_init.h"
|
||||
|
||||
namespace esphome::ili9xxx {
|
||||
|
||||
static const char *const TAG = "ili9xxx";
|
||||
ESPHOME_LOG_TAG(TAG, "ili9xxx");
|
||||
const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6
|
||||
|
||||
enum ILI9XXXColorMode {
|
||||
|
||||
@@ -145,13 +145,42 @@ def is_alpha_only(image: Image):
|
||||
return all(b.getextrema()[1] == 0 for b in image.split()[:-1])
|
||||
|
||||
|
||||
class ImageBinary(ImageEncoder):
|
||||
class BitPackedEncoder(ImageEncoder):
|
||||
"""Packs pixels MSB first, with every row padded to a whole byte."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bpp: int,
|
||||
width: int,
|
||||
height: int,
|
||||
transparency: str,
|
||||
dither: Image.Dither,
|
||||
invert_alpha: bool,
|
||||
):
|
||||
self.bpp = bpp
|
||||
super().__init__(
|
||||
(width * bpp + 7) // 8, height, transparency, dither, invert_alpha
|
||||
)
|
||||
self.bitno = 0
|
||||
|
||||
def add_bit(self, bit: bool) -> None:
|
||||
if bit:
|
||||
self.data[self.index] |= 0x80 >> self.bitno
|
||||
self.bitno += 1
|
||||
if self.bitno == 8:
|
||||
self.end_row()
|
||||
|
||||
def end_row(self) -> None:
|
||||
if self.bitno != 0:
|
||||
self.bitno = 0
|
||||
self.index += 1
|
||||
|
||||
|
||||
class ImageBinary(BitPackedEncoder):
|
||||
allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY}
|
||||
|
||||
def __init__(self, width, height, transparency, dither, invert_alpha):
|
||||
self.width8 = (width + 7) // 8
|
||||
super().__init__(self.width8, height, transparency, dither, invert_alpha)
|
||||
self.bitno = 0
|
||||
super().__init__(1, width, height, transparency, dither, invert_alpha)
|
||||
|
||||
def convert(self, image, path):
|
||||
if is_alpha_only(image):
|
||||
@@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder):
|
||||
return image.convert("1", dither=self.dither)
|
||||
|
||||
def encode(self, pixel):
|
||||
if self.invert_alpha:
|
||||
pixel = not pixel
|
||||
if pixel:
|
||||
self.data[self.index] |= 0x80 >> (self.bitno % 8)
|
||||
self.bitno += 1
|
||||
if self.bitno == 8:
|
||||
self.bitno = 0
|
||||
self.index += 1
|
||||
self.add_bit(bool(pixel) != self.invert_alpha)
|
||||
|
||||
def end_row(self):
|
||||
"""
|
||||
Pad rows to a byte boundary
|
||||
"""
|
||||
if self.bitno != 0:
|
||||
self.bitno = 0
|
||||
self.index += 1
|
||||
|
||||
# The eight colours one bit per channel can express, for dithering.
|
||||
_RGB111_PALETTE = Image.new("P", (1, 1))
|
||||
_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"])
|
||||
|
||||
|
||||
class ImageRGB111(BitPackedEncoder):
|
||||
allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
transparency: str,
|
||||
dither: Image.Dither,
|
||||
invert_alpha: bool,
|
||||
):
|
||||
bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3
|
||||
super().__init__(bpp, width, height, transparency, dither, invert_alpha)
|
||||
|
||||
def convert(self, image: Image, path: str):
|
||||
image = image.convert("RGBA")
|
||||
if self.dither != Image.Dither.FLOYDSTEINBERG:
|
||||
return image
|
||||
alpha = image.getchannel("A")
|
||||
image = image.convert("RGB").quantize(
|
||||
palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG
|
||||
)
|
||||
image = image.convert("RGB")
|
||||
image.putalpha(alpha)
|
||||
return image
|
||||
|
||||
def encode(self, pixel: tuple[int, int, int, int]):
|
||||
r, g, b, a = pixel
|
||||
if self.invert_alpha:
|
||||
a ^= 0xFF
|
||||
for channel in (r, g, b, a)[: self.bpp]:
|
||||
self.add_bit(channel > 127)
|
||||
|
||||
|
||||
class ImageGrayscale(ImageEncoder):
|
||||
@@ -315,6 +368,7 @@ class ReplaceWith:
|
||||
IMAGE_TYPE = {
|
||||
"BINARY": ImageBinary,
|
||||
"GRAYSCALE": ImageGrayscale,
|
||||
"RGB111": ImageRGB111,
|
||||
"RGB565": ImageRGB565,
|
||||
"RGB": ImageRGB,
|
||||
"TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"),
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "image");
|
||||
|
||||
namespace esphome::image {
|
||||
|
||||
void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
|
||||
@@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
|
||||
}
|
||||
break;
|
||||
}
|
||||
case IMAGE_TYPE_RGB111: {
|
||||
for (int img_x = img_x0; img_x < w; img_x++) {
|
||||
for (int img_y = img_y0; img_y < h; img_y++) {
|
||||
auto color = this->get_rgb111_pixel_(img_x, img_y);
|
||||
if (color.w >= 0x80) {
|
||||
display->draw_pixel_at(x + img_x, y + img_y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case IMAGE_TYPE_GRAYSCALE:
|
||||
for (int img_x = img_x0; img_x < w; img_x++) {
|
||||
for (int img_y = img_y0; img_y < h; img_y++) {
|
||||
@@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
|
||||
if (this->get_binary_pixel_(x, y))
|
||||
return color_on;
|
||||
return color_off;
|
||||
case IMAGE_TYPE_RGB111:
|
||||
return this->get_rgb111_pixel_(x, y);
|
||||
case IMAGE_TYPE_GRAYSCALE:
|
||||
return this->get_grayscale_pixel_(x, y);
|
||||
case IMAGE_TYPE_RGB565:
|
||||
@@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() {
|
||||
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
|
||||
break;
|
||||
|
||||
case IMAGE_TYPE_RGB111:
|
||||
// Rejected by the lvgl config validation
|
||||
ESP_LOGE(TAG, "RGB111 images are not supported in LVGL.");
|
||||
break;
|
||||
case IMAGE_TYPE_RGB:
|
||||
switch (this->transparency_) {
|
||||
case TRANSPARENCY_ALPHA_CHANNEL:
|
||||
@@ -152,6 +171,19 @@ bool Image::get_binary_pixel_(int x, int y) const {
|
||||
const uint32_t pos = x + y * width_8;
|
||||
return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u));
|
||||
}
|
||||
Color Image::get_rgb111_pixel_(int x, int y) const {
|
||||
const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_;
|
||||
const uint8_t *byte = this->data_start_ + bitpos / 8;
|
||||
const uint8_t shift = bitpos % 8;
|
||||
// A pixel can straddle two bytes; read the second one only when it does.
|
||||
uint16_t window = progmem_read_byte(byte) << 8;
|
||||
if (shift + this->bpp_ > 8)
|
||||
window |= progmem_read_byte(byte + 1);
|
||||
const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1);
|
||||
const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits;
|
||||
const bool opaque = this->bpp_ == 3 || (bits & 1);
|
||||
return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00);
|
||||
}
|
||||
Color Image::get_rgb_pixel_(int x, int y) const {
|
||||
const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8;
|
||||
Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1),
|
||||
@@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T
|
||||
case IMAGE_TYPE_BINARY:
|
||||
this->bpp_ = 1;
|
||||
break;
|
||||
case IMAGE_TYPE_RGB111:
|
||||
this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3;
|
||||
break;
|
||||
case IMAGE_TYPE_GRAYSCALE:
|
||||
this->bpp_ = 8;
|
||||
break;
|
||||
|
||||
@@ -13,6 +13,7 @@ enum ImageType {
|
||||
IMAGE_TYPE_GRAYSCALE = 1,
|
||||
IMAGE_TYPE_RGB = 2,
|
||||
IMAGE_TYPE_RGB565 = 3,
|
||||
IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper
|
||||
};
|
||||
|
||||
enum Transparency {
|
||||
@@ -44,6 +45,7 @@ class Image : public display::BaseImage {
|
||||
#endif
|
||||
protected:
|
||||
bool get_binary_pixel_(int x, int y) const;
|
||||
Color get_rgb111_pixel_(int x, int y) const;
|
||||
Color get_rgb_pixel_(int x, int y) const;
|
||||
Color get_rgb565_pixel_(int x, int y) const;
|
||||
Color get_grayscale_pixel_(int x, int y) const;
|
||||
|
||||
@@ -13,6 +13,24 @@ static const uint8_t INA3221_REGISTER_CHANNEL2_SHUNT_VOLTAGE = 0x03;
|
||||
static const uint8_t INA3221_REGISTER_CHANNEL2_BUS_VOLTAGE = 0x04;
|
||||
static const uint8_t INA3221_REGISTER_CHANNEL3_SHUNT_VOLTAGE = 0x05;
|
||||
static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06;
|
||||
#ifdef USE_INA3221_ALERT_LIMITS
|
||||
static constexpr uint8_t INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT = 0x07;
|
||||
static constexpr uint8_t INA3221_REGISTER_CHANNEL1_WARNING_ALERT = 0x0A;
|
||||
#endif
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
static constexpr uint8_t INA3221_REGISTER_SHUNT_VOLTAGE_SUM = 0x0D;
|
||||
static constexpr uint8_t INA3221_REGISTER_MASK_ENABLE = 0x0F;
|
||||
#endif
|
||||
|
||||
// Configuration register: bit 15 resets the chip, bits 14..12 enable channels 1..3 (the same bit
|
||||
// positions select channels for summation in the mask/enable register) and bits 2..0 set the mode.
|
||||
// Code generation fills in the averaging and conversion time fields.
|
||||
static constexpr uint16_t INA3221_CONFIG_RESET = 0x8000;
|
||||
static constexpr uint16_t INA3221_CONFIG_CHANNEL1_ENABLE = 0x4000;
|
||||
static constexpr uint16_t INA3221_CONFIG_CHANNEL_MASK = 0x7000;
|
||||
static constexpr uint16_t INA3221_CONFIG_MODE_MASK = 0x0007;
|
||||
|
||||
static constexpr uint32_t READ_TIMEOUT_ID = 0;
|
||||
|
||||
// Addresses:
|
||||
// A0 = GND -> 0x40
|
||||
@@ -21,37 +39,38 @@ static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06;
|
||||
// A0 = SCL -> 0x43
|
||||
|
||||
void INA3221Component::setup() {
|
||||
// Config Register
|
||||
// 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset)
|
||||
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, 0x8000)) {
|
||||
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
delay(1);
|
||||
|
||||
uint16_t config = 0;
|
||||
// 0b0xxx000000000000 << 12 Channel Enables (1 -> ON)
|
||||
if (this->channels_[0].exists()) {
|
||||
config |= 0b0100000000000000;
|
||||
}
|
||||
if (this->channels_[1].exists()) {
|
||||
config |= 0b0010000000000000;
|
||||
}
|
||||
if (this->channels_[2].exists()) {
|
||||
config |= 0b0001000000000000;
|
||||
}
|
||||
// 0b0000xxx000000000 << 9 Averaging Mode (0 -> 1 sample, 111 -> 1024 samples)
|
||||
config |= 0b0000000000000000;
|
||||
// 0b0000000xxx000000 << 6 Bus Voltage Conversion time (100 -> 1.1ms, 111 -> 8.244 ms)
|
||||
config |= 0b0000000111000000;
|
||||
// 0b0000000000xxx000 << 3 Shunt Voltage Conversion time (same as above)
|
||||
config |= 0b0000000000111000;
|
||||
// 0b0000000000000xxx << 0 Operating mode (111 -> Shunt and bus, continuous)
|
||||
config |= 0b0000000000000111;
|
||||
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, config)) {
|
||||
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_INA3221_ALERT_LIMITS
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
const INA3221Channel &channel = this->channels_[i];
|
||||
if ((channel.critical_limit_ != 0 &&
|
||||
!this->write_byte_16(INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT + i, channel.critical_limit_)) ||
|
||||
(channel.warning_limit_ != 0 &&
|
||||
!this->write_byte_16(INA3221_REGISTER_CHANNEL1_WARNING_ALERT + i, channel.warning_limit_))) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
// Sum the shunt voltages of every enabled channel
|
||||
if (this->sum_shunt_voltage_sensor_ != nullptr &&
|
||||
!this->write_byte_16(INA3221_REGISTER_MASK_ENABLE, this->config_ & INA3221_CONFIG_CHANNEL_MASK)) {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void INA3221Component::dump_config() {
|
||||
@@ -61,6 +80,10 @@ void INA3221Component::dump_config() {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
}
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
ESP_LOGCONFIG(TAG, " Config register: 0x%04X", this->config_);
|
||||
if (this->single_shot_wait_ms_ != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Single-shot conversion: %u ms", this->single_shot_wait_ms_);
|
||||
}
|
||||
|
||||
LOG_SENSOR(" ", "Bus Voltage #1", this->channels_[0].bus_voltage_sensor_);
|
||||
LOG_SENSOR(" ", "Shunt Voltage #1", this->channels_[0].shunt_voltage_sensor_);
|
||||
@@ -74,6 +97,11 @@ void INA3221Component::dump_config() {
|
||||
LOG_SENSOR(" ", "Shunt Voltage #3", this->channels_[2].shunt_voltage_sensor_);
|
||||
LOG_SENSOR(" ", "Current #3", this->channels_[2].current_sensor_);
|
||||
LOG_SENSOR(" ", "Power #3", this->channels_[2].power_sensor_);
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
LOG_SENSOR(" ", "Sum Shunt Voltage", this->sum_shunt_voltage_sensor_);
|
||||
LOG_SENSOR(" ", "Sum Current", this->sum_current_sensor_);
|
||||
LOG_SENSOR(" ", "Sum Power", this->sum_power_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel * 2; }
|
||||
@@ -81,11 +109,36 @@ inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel
|
||||
inline uint8_t ina3221_shunt_voltage_register(int channel) { return 0x01 + channel * 2; }
|
||||
|
||||
void INA3221Component::update() {
|
||||
if (this->single_shot_wait_ms_ == 0) {
|
||||
this->read_data_();
|
||||
return;
|
||||
}
|
||||
// In single-shot mode, writing the configuration register starts one conversion of every channel
|
||||
if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
this->set_timeout(READ_TIMEOUT_ID, this->single_shot_wait_ms_, [this]() { this->read_data_(); });
|
||||
}
|
||||
|
||||
void INA3221Component::read_data_() {
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
// The software sums need every enabled channel's readings, not only the ones with their own sensors
|
||||
const bool sum_all = this->sum_current_sensor_ != nullptr || this->sum_power_sensor_ != nullptr;
|
||||
float total_current_a = 0.0f;
|
||||
float total_power_w = 0.0f;
|
||||
#else
|
||||
constexpr bool sum_all = false;
|
||||
#endif
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if ((this->config_ & (INA3221_CONFIG_CHANNEL1_ENABLE >> i)) == 0) {
|
||||
continue;
|
||||
}
|
||||
INA3221Channel &channel = this->channels_[i];
|
||||
float bus_voltage_v = NAN, current_a = NAN;
|
||||
uint16_t raw;
|
||||
if (channel.should_measure_bus_voltage()) {
|
||||
if (sum_all || channel.should_measure_bus_voltage()) {
|
||||
if (!this->read_byte_16(ina3221_bus_voltage_register(i), &raw)) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
@@ -94,7 +147,7 @@ void INA3221Component::update() {
|
||||
if (channel.bus_voltage_sensor_ != nullptr)
|
||||
channel.bus_voltage_sensor_->publish_state(bus_voltage_v);
|
||||
}
|
||||
if (channel.should_measure_shunt_voltage()) {
|
||||
if (sum_all || channel.should_measure_shunt_voltage()) {
|
||||
if (!this->read_byte_16(ina3221_shunt_voltage_register(i), &raw)) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
@@ -109,17 +162,42 @@ void INA3221Component::update() {
|
||||
if (channel.power_sensor_ != nullptr) {
|
||||
channel.power_sensor_->publish_state(bus_voltage_v * current_a);
|
||||
}
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
if (sum_all) {
|
||||
total_current_a += current_a;
|
||||
total_power_w += bus_voltage_v * current_a;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
if (this->sum_shunt_voltage_sensor_ != nullptr) {
|
||||
uint16_t raw;
|
||||
if (!this->read_byte_16(INA3221_REGISTER_SHUNT_VOLTAGE_SUM, &raw)) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
// 40 uV per step, held in bits 15..1
|
||||
this->sum_shunt_voltage_sensor_->publish_state(int16_t(raw) * 40.0f / 2.0f / 1000000.0f);
|
||||
}
|
||||
if (this->sum_current_sensor_ != nullptr) {
|
||||
this->sum_current_sensor_->publish_state(total_current_a);
|
||||
}
|
||||
if (this->sum_power_sensor_ != nullptr) {
|
||||
this->sum_power_sensor_->publish_state(total_power_w);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void INA3221Component::on_powerdown() {
|
||||
// Mode 0 powers the chip down; setup() resets it on the next boot
|
||||
this->write_byte_16(INA3221_REGISTER_CONFIG, static_cast<uint16_t>(this->config_ & ~INA3221_CONFIG_MODE_MASK));
|
||||
}
|
||||
|
||||
void INA3221Component::set_shunt_resistance(int channel, float resistance_ohm) {
|
||||
this->channels_[channel].shunt_resistance_ = resistance_ohm;
|
||||
}
|
||||
|
||||
bool INA3221Component::INA3221Channel::exists() {
|
||||
return this->bus_voltage_sensor_ != nullptr || this->shunt_voltage_sensor_ != nullptr ||
|
||||
this->current_sensor_ != nullptr || this->power_sensor_ != nullptr;
|
||||
}
|
||||
bool INA3221Component::INA3221Channel::should_measure_shunt_voltage() {
|
||||
return this->shunt_voltage_sensor_ != nullptr || this->current_sensor_ != nullptr || this->power_sensor_ != nullptr;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
namespace esphome::ina3221 {
|
||||
|
||||
class INA3221Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
#ifdef USE_INA3221_SUMMATION
|
||||
SUB_SENSOR(sum_shunt_voltage)
|
||||
SUB_SENSOR(sum_current)
|
||||
SUB_SENSOR(sum_power)
|
||||
#endif
|
||||
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
void on_powerdown() override;
|
||||
|
||||
void set_bus_voltage_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].bus_voltage_sensor_ = obj; }
|
||||
void set_shunt_voltage_sensor(int channel, sensor::Sensor *obj) {
|
||||
@@ -19,19 +26,35 @@ class INA3221Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
void set_current_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].current_sensor_ = obj; }
|
||||
void set_power_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].power_sensor_ = obj; }
|
||||
void set_shunt_resistance(int channel, float resistance_ohm);
|
||||
/// Configuration register value built by code generation: channel enables, averaging, conversion times, mode
|
||||
void set_config_register(uint16_t config) { this->config_ = config; }
|
||||
/// Time one single-shot conversion of every enabled channel takes; zero means continuous mode
|
||||
void set_single_shot_wait_ms(uint16_t wait_ms) { this->single_shot_wait_ms_ = wait_ms; }
|
||||
#ifdef USE_INA3221_ALERT_LIMITS
|
||||
void set_warning_limit(int channel, uint16_t reg) { this->channels_[channel].warning_limit_ = reg; }
|
||||
void set_critical_limit(int channel, uint16_t reg) { this->channels_[channel].critical_limit_ = reg; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
void read_data_();
|
||||
|
||||
struct INA3221Channel {
|
||||
float shunt_resistance_{0.1f};
|
||||
sensor::Sensor *bus_voltage_sensor_{nullptr};
|
||||
sensor::Sensor *shunt_voltage_sensor_{nullptr};
|
||||
sensor::Sensor *current_sensor_{nullptr};
|
||||
sensor::Sensor *power_sensor_{nullptr};
|
||||
#ifdef USE_INA3221_ALERT_LIMITS
|
||||
// Alert limit register values; zero keeps the chip default, which never trips
|
||||
uint16_t warning_limit_{0};
|
||||
uint16_t critical_limit_{0};
|
||||
#endif
|
||||
|
||||
bool exists();
|
||||
bool should_measure_shunt_voltage();
|
||||
bool should_measure_bus_voltage();
|
||||
} channels_[3];
|
||||
uint16_t config_{0};
|
||||
uint16_t single_shot_wait_ms_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::ina3221
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensor
|
||||
from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUS_VOLTAGE,
|
||||
@@ -8,6 +11,7 @@ from esphome.const import (
|
||||
CONF_POWER,
|
||||
CONF_SHUNT_RESISTANCE,
|
||||
CONF_SHUNT_VOLTAGE,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEVICE_CLASS_CURRENT,
|
||||
DEVICE_CLASS_POWER,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
@@ -23,69 +27,195 @@ DEPENDENCIES = ["i2c"]
|
||||
CONF_CHANNEL_1 = "channel_1"
|
||||
CONF_CHANNEL_2 = "channel_2"
|
||||
CONF_CHANNEL_3 = "channel_3"
|
||||
CONF_CONTINUOUS_MODE = "continuous_mode"
|
||||
CONF_WARNING_CURRENT_LIMIT = "warning_current_limit"
|
||||
CONF_CRITICAL_CURRENT_LIMIT = "critical_current_limit"
|
||||
CONF_SUM_SHUNT_VOLTAGE = "sum_shunt_voltage"
|
||||
CONF_SUM_CURRENT = "sum_current"
|
||||
CONF_SUM_POWER = "sum_power"
|
||||
|
||||
CHANNELS = (CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3)
|
||||
|
||||
# Configuration register layout: channel enables in bits 14..12, averaging in 11..9,
|
||||
# bus conversion time in 8..6, shunt conversion time in 5..3, operating mode in 2..0
|
||||
CHANNEL_ENABLE_BITS = (1 << 14, 1 << 13, 1 << 12)
|
||||
ADC_AVERAGING_SHIFT = 9
|
||||
ADC_TIME_BUS_SHIFT = 6
|
||||
ADC_TIME_SHUNT_SHIFT = 3
|
||||
MODE_SINGLE_SHOT = 0b011
|
||||
MODE_CONTINUOUS = 0b111
|
||||
|
||||
# Register field value by sample count and by conversion time in microseconds
|
||||
ADC_AVERAGING = {1: 0, 4: 1, 16: 2, 64: 3, 128: 4, 256: 5, 512: 6, 1024: 7}
|
||||
ADC_TIMES_US = {140: 0, 204: 1, 332: 2, 588: 3, 1100: 4, 2116: 5, 4156: 6, 8244: 7}
|
||||
|
||||
# Alert limits are shunt voltages in 40 uV steps, held in bits 15..3
|
||||
ALERT_LIMIT_LSB_V = 40e-6
|
||||
ALERT_LIMIT_MAX_V = 0x0FFF * ALERT_LIMIT_LSB_V
|
||||
# Headroom on top of the computed conversion time before the results are read
|
||||
SINGLE_SHOT_MARGIN_MS = 5
|
||||
|
||||
ina3221_ns = cg.esphome_ns.namespace("ina3221")
|
||||
INA3221Component = ina3221_ns.class_(
|
||||
"INA3221Component", cg.PollingComponent, i2c.I2CDevice
|
||||
)
|
||||
|
||||
INA3221_CHANNEL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_BUS_VOLTAGE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_VOLT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_SHUNT_VOLTAGE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_VOLT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_CURRENT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_POWER): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_WATT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All(
|
||||
cv.resistance, cv.Range(min=0.0, max=32.0)
|
||||
),
|
||||
}
|
||||
VOLTAGE_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_VOLT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
CURRENT_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_CURRENT,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
POWER_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_WATT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
CURRENT_LIMIT_SCHEMA = cv.All(cv.current, cv.Range(min=0.0, min_included=False))
|
||||
|
||||
|
||||
def validate_adc_time(value: Any) -> int:
|
||||
value = cv.positive_time_period_microseconds(value).total_microseconds
|
||||
return cv.one_of(*ADC_TIMES_US, int=True)(value)
|
||||
|
||||
|
||||
def validate_alert_limits(config: ConfigType) -> ConfigType:
|
||||
shunt = config[CONF_SHUNT_RESISTANCE]
|
||||
for key in (CONF_WARNING_CURRENT_LIMIT, CONF_CRITICAL_CURRENT_LIMIT):
|
||||
if (limit := config.get(key)) is None:
|
||||
continue
|
||||
if limit * shunt > ALERT_LIMIT_MAX_V:
|
||||
raise cv.Invalid(
|
||||
f"{key} times shunt_resistance exceeds the "
|
||||
f"{ALERT_LIMIT_MAX_V * 1000:.1f} mV alert range",
|
||||
path=[key],
|
||||
)
|
||||
if alert_limit_register(limit, shunt) == 0:
|
||||
raise cv.Invalid(
|
||||
f"{key} times shunt_resistance is below the "
|
||||
f"{ALERT_LIMIT_LSB_V * 1e6:.0f} µV alert resolution",
|
||||
path=[key],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
INA3221_CHANNEL_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_BUS_VOLTAGE): VOLTAGE_SCHEMA,
|
||||
cv.Optional(CONF_SHUNT_VOLTAGE): VOLTAGE_SCHEMA,
|
||||
cv.Optional(CONF_CURRENT): CURRENT_SCHEMA,
|
||||
cv.Optional(CONF_POWER): POWER_SCHEMA,
|
||||
cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All(
|
||||
cv.resistance, cv.Range(min=0.0, max=32.0)
|
||||
),
|
||||
cv.Optional(CONF_WARNING_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA,
|
||||
cv.Optional(CONF_CRITICAL_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(
|
||||
CONF_BUS_VOLTAGE,
|
||||
CONF_SHUNT_VOLTAGE,
|
||||
CONF_CURRENT,
|
||||
CONF_POWER,
|
||||
CONF_WARNING_CURRENT_LIMIT,
|
||||
CONF_CRITICAL_CURRENT_LIMIT,
|
||||
),
|
||||
validate_alert_limits,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
|
||||
def adc_times_us(config: ConfigType) -> tuple[int, int]:
|
||||
"""Return the bus and shunt conversion times in microseconds."""
|
||||
adc_time = config[CONF_ADC_TIME]
|
||||
if isinstance(adc_time, dict):
|
||||
return adc_time[CONF_BUS_VOLTAGE], adc_time[CONF_SHUNT_VOLTAGE]
|
||||
return adc_time, adc_time
|
||||
|
||||
|
||||
def single_shot_wait_ms(config: ConfigType) -> int:
|
||||
"""Time one conversion of every enabled channel takes, plus headroom."""
|
||||
bus_us, shunt_us = adc_times_us(config)
|
||||
channels = sum(1 for channel in CHANNELS if channel in config)
|
||||
total_us = (bus_us + shunt_us) * config[CONF_ADC_AVERAGING] * channels
|
||||
return -(-total_us // 1000) + SINGLE_SHOT_MARGIN_MS
|
||||
|
||||
|
||||
def validate_single_shot_interval(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_CONTINUOUS_MODE]:
|
||||
return config
|
||||
wait_ms = single_shot_wait_ms(config)
|
||||
if config[CONF_UPDATE_INTERVAL].total_milliseconds <= wait_ms:
|
||||
raise cv.Invalid(
|
||||
f"A single-shot conversion of all channels takes {wait_ms} ms, which does "
|
||||
f"not fit in the update_interval; lower {CONF_ADC_AVERAGING} or "
|
||||
f"{CONF_ADC_TIME}, or raise the interval",
|
||||
path=[CONF_UPDATE_INTERVAL],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(INA3221Component),
|
||||
cv.Optional(CONF_CHANNEL_1): INA3221_CHANNEL_SCHEMA,
|
||||
cv.Optional(CONF_CHANNEL_2): INA3221_CHANNEL_SCHEMA,
|
||||
cv.Optional(CONF_CHANNEL_3): INA3221_CHANNEL_SCHEMA,
|
||||
cv.Optional(CONF_CONTINUOUS_MODE, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ADC_AVERAGING, default=1): cv.one_of(
|
||||
*ADC_AVERAGING, int=True
|
||||
),
|
||||
cv.Optional(CONF_ADC_TIME, default="8244us"): cv.Any(
|
||||
validate_adc_time,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_BUS_VOLTAGE): validate_adc_time,
|
||||
cv.Required(CONF_SHUNT_VOLTAGE): validate_adc_time,
|
||||
}
|
||||
),
|
||||
),
|
||||
cv.Optional(CONF_SUM_SHUNT_VOLTAGE): VOLTAGE_SCHEMA,
|
||||
cv.Optional(CONF_SUM_CURRENT): CURRENT_SCHEMA,
|
||||
cv.Optional(CONF_SUM_POWER): POWER_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(i2c.i2c_device_schema(0x40))
|
||||
.extend(i2c.i2c_device_schema(0x40)),
|
||||
cv.has_at_least_one_key(*CHANNELS),
|
||||
validate_single_shot_interval,
|
||||
)
|
||||
|
||||
|
||||
def alert_limit_register(current_a: float, shunt_ohm: float) -> int:
|
||||
return round(current_a * shunt_ohm / ALERT_LIMIT_LSB_V) << 3
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
for i, channel in enumerate([CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3]):
|
||||
if channel not in config:
|
||||
bus_us, shunt_us = adc_times_us(config)
|
||||
register = (
|
||||
ADC_AVERAGING[config[CONF_ADC_AVERAGING]] << ADC_AVERAGING_SHIFT
|
||||
| ADC_TIMES_US[bus_us] << ADC_TIME_BUS_SHIFT
|
||||
| ADC_TIMES_US[shunt_us] << ADC_TIME_SHUNT_SHIFT
|
||||
| (MODE_CONTINUOUS if config[CONF_CONTINUOUS_MODE] else MODE_SINGLE_SHOT)
|
||||
)
|
||||
for i, channel in enumerate(CHANNELS):
|
||||
if (conf := config.get(channel)) is None:
|
||||
continue
|
||||
conf = config[channel]
|
||||
if CONF_SHUNT_RESISTANCE in conf:
|
||||
cg.add(var.set_shunt_resistance(i, conf[CONF_SHUNT_RESISTANCE]))
|
||||
register |= CHANNEL_ENABLE_BITS[i]
|
||||
shunt = conf[CONF_SHUNT_RESISTANCE]
|
||||
cg.add(var.set_shunt_resistance(i, shunt))
|
||||
if CONF_BUS_VOLTAGE in conf:
|
||||
sens = await sensor.new_sensor(conf[CONF_BUS_VOLTAGE])
|
||||
cg.add(var.set_bus_voltage_sensor(i, sens))
|
||||
@@ -98,3 +228,23 @@ async def to_code(config: ConfigType) -> None:
|
||||
if CONF_POWER in conf:
|
||||
sens = await sensor.new_sensor(conf[CONF_POWER])
|
||||
cg.add(var.set_power_sensor(i, sens))
|
||||
for key, setter in (
|
||||
(CONF_WARNING_CURRENT_LIMIT, var.set_warning_limit),
|
||||
(CONF_CRITICAL_CURRENT_LIMIT, var.set_critical_limit),
|
||||
):
|
||||
if (limit := conf.get(key)) is not None:
|
||||
cg.add_define("USE_INA3221_ALERT_LIMITS")
|
||||
cg.add(setter(i, alert_limit_register(limit, shunt)))
|
||||
cg.add(var.set_config_register(register))
|
||||
if not config[CONF_CONTINUOUS_MODE]:
|
||||
cg.add(var.set_single_shot_wait_ms(single_shot_wait_ms(config)))
|
||||
|
||||
if any(
|
||||
key in config
|
||||
for key in (CONF_SUM_SHUNT_VOLTAGE, CONF_SUM_CURRENT, CONF_SUM_POWER)
|
||||
):
|
||||
cg.add_define("USE_INA3221_SUMMATION")
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_SUM_SHUNT_VOLTAGE, var.set_sum_shunt_voltage_sensor)
|
||||
await sensors(CONF_SUM_CURRENT, var.set_sum_current_sensor)
|
||||
await sensors(CONF_SUM_POWER, var.set_sum_power_sensor)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -4,6 +4,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["ld24xx"]
|
||||
CODEOWNERS = ["@NovakIrs"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "ld2410s"
|
||||
@@ -13,6 +14,7 @@ CONF_LD2410S_ID = "ld2410s_id"
|
||||
|
||||
ld2410s_ns = cg.esphome_ns.namespace(DOMAIN)
|
||||
LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice)
|
||||
Parameter = ld2410s_ns.enum("Parameter", is_class=True)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ld2410s.h"
|
||||
#include "ld2410s_number.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include "esphome/core/application.h"
|
||||
@@ -25,18 +26,46 @@ static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word
|
||||
static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF;
|
||||
static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001;
|
||||
static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE;
|
||||
static constexpr uint16_t VERSION_READ_CMD = 0x0000;
|
||||
static constexpr uint16_t PARAMETERS_WRITE_CMD = 0x0070;
|
||||
static constexpr uint16_t PARAMETERS_READ_CMD = 0x0071;
|
||||
static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A;
|
||||
// Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes
|
||||
static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6;
|
||||
static constexpr uint8_t CMD_FRAME_MAX_SIZE =
|
||||
LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER);
|
||||
// The parameter write carries a 16-bit key and a 32-bit value per parameter, the read only the keys
|
||||
static constexpr uint8_t PARAMETER_KEY_SIZE = 2;
|
||||
static constexpr uint8_t PARAMETER_VALUE_SIZE = 4;
|
||||
static constexpr uint8_t PARAMETERS_WRITE_SIZE = PARAMETER_COUNT * (PARAMETER_KEY_SIZE + PARAMETER_VALUE_SIZE);
|
||||
static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_WRITE_SIZE + sizeof(CMD_FRAME_FOOTER);
|
||||
// Keys of the parameters, in Parameter order
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
|
||||
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
|
||||
#endif
|
||||
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
|
||||
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
|
||||
static constexpr uint8_t VERSION_SIZE = VERSION_TYPE_SIZE + 3 * 2;
|
||||
|
||||
// Everything the module needs at start: enter configuration mode, pick the output mode, leave it
|
||||
static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD,
|
||||
CONFIG_MODE_END_CMD};
|
||||
// Everything the module needs at start: configuration mode on, read the version and parameters when
|
||||
// an entity shows them, pick the output mode, configuration mode off
|
||||
static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {
|
||||
CONFIG_MODE_START_CMD,
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
VERSION_READ_CMD,
|
||||
#endif
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
PARAMETERS_READ_CMD,
|
||||
#endif
|
||||
OUTPUT_MODE_SWITCH_CMD, CONFIG_MODE_END_CMD,
|
||||
};
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
static constexpr uint16_t WRITE_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, PARAMETERS_WRITE_CMD, CONFIG_MODE_END_CMD};
|
||||
#endif
|
||||
static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]);
|
||||
static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); }
|
||||
static constexpr uint8_t INIT_MAX_TIMEOUTS = 2;
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
static constexpr uint8_t WRITE_SEQUENCE_LENGTH = sizeof(WRITE_SEQUENCE) / sizeof(WRITE_SEQUENCE[0]);
|
||||
#endif
|
||||
static constexpr uint8_t STEP_MAX_TIMEOUTS = 2;
|
||||
static constexpr uint32_t ACK_TIMEOUT_MS = 300;
|
||||
static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command
|
||||
static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering
|
||||
@@ -49,6 +78,20 @@ void LD2410SComponent::dump_config() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Distance", this->distance_sensor_);
|
||||
#endif
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_);
|
||||
#endif
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
for (auto *number : this->numbers_) {
|
||||
LOG_NUMBER(" ", "Setting", number);
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||
LOG_SELECT(" ", "Response Speed", this->response_speed_select_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LD2410SComponent::loop() {
|
||||
@@ -67,35 +110,80 @@ void LD2410SComponent::loop() {
|
||||
avail -= len;
|
||||
}
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->run_init_sequence_(now);
|
||||
if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) {
|
||||
if (this->sequence_ == nullptr) {
|
||||
if (!this->init_done_) {
|
||||
this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, now);
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
} else if (this->parameters_dirty_) {
|
||||
this->parameters_dirty_ = false;
|
||||
this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
this->run_sequence_(now);
|
||||
if (this->init_done_ && now - this->last_frame_at_ > LINK_TIMEOUT_MS) {
|
||||
// The count tells real silence apart from frames that never parse (wrong baud rate or firmware)
|
||||
ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000,
|
||||
this->dropped_frames_);
|
||||
this->dropped_frames_ = 0;
|
||||
this->status_set_warning();
|
||||
this->init_step_ = 0;
|
||||
this->init_timeouts_ = 0;
|
||||
this->awaiting_ack_ = false;
|
||||
this->next_send_at_ = now;
|
||||
this->restart_init_(now);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2410SComponent::run_init_sequence_(uint32_t now) {
|
||||
if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast<int32_t>(now - this->next_send_at_) < 0) {
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
|
||||
// All six are written together, so the other five must be the module's own values
|
||||
if (!this->init_done_) {
|
||||
ESP_LOGW(TAG, "Parameters not read yet, change ignored");
|
||||
return false;
|
||||
}
|
||||
if (!this->parameters_known_) {
|
||||
ESP_LOGW(TAG, "The module reported a parameter out of range, changes are disabled");
|
||||
return false;
|
||||
}
|
||||
this->parameters_[static_cast<uint8_t>(parameter)] = value;
|
||||
this->parameters_dirty_ = true; // written by loop() once no sequence is running
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void LD2410SComponent::restart_init_(uint32_t at) {
|
||||
this->init_done_ = false;
|
||||
this->status_set_warning(); // cleared again when the new init sequence is acknowledged
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
// The module's own values count again once it is set up anew
|
||||
if (this->parameters_dirty_) {
|
||||
ESP_LOGW(TAG, "Dropping the pending parameter change, the module's values are read back");
|
||||
}
|
||||
this->parameters_dirty_ = false;
|
||||
this->parameters_known_ = false; // a refused value may sit in parameters_ until the read replaces it
|
||||
#endif
|
||||
this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, at);
|
||||
}
|
||||
|
||||
void LD2410SComponent::start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now) {
|
||||
this->sequence_ = sequence;
|
||||
this->sequence_len_ = length;
|
||||
this->step_ = 0;
|
||||
this->step_timeouts_ = 0;
|
||||
this->awaiting_ack_ = false;
|
||||
this->next_send_at_ = now;
|
||||
}
|
||||
|
||||
uint16_t LD2410SComponent::current_command_() const { return progmem_read_uint16(&this->sequence_[this->step_]); }
|
||||
|
||||
void LD2410SComponent::run_sequence_(uint32_t now) {
|
||||
if (this->sequence_ == nullptr || static_cast<int32_t>(now - this->next_send_at_) < 0) {
|
||||
return;
|
||||
}
|
||||
const uint16_t command = init_command(this->init_step_);
|
||||
const uint16_t command = this->current_command_();
|
||||
if (this->awaiting_ack_) {
|
||||
if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) {
|
||||
if (++this->step_timeouts_ > STEP_MAX_TIMEOUTS) {
|
||||
ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command,
|
||||
REINIT_PAUSE_MS / 1000);
|
||||
// Leave configuration mode whatever state the module is in, then begin again
|
||||
this->send_command_(CONFIG_MODE_END_CMD);
|
||||
this->init_step_ = 0;
|
||||
this->init_timeouts_ = 0;
|
||||
this->awaiting_ack_ = false;
|
||||
this->next_send_at_ = now + REINIT_PAUSE_MS;
|
||||
this->restart_init_(now + REINIT_PAUSE_MS);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command);
|
||||
@@ -120,6 +208,22 @@ void LD2410SComponent::send_command_(uint16_t command) {
|
||||
frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF;
|
||||
frame[pos++] = CONFIG_MODE_START_VALUE >> 8;
|
||||
break;
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
case PARAMETERS_READ_CMD:
|
||||
case PARAMETERS_WRITE_CMD:
|
||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||
const uint16_t key = progmem_read_uint16(&PARAMETER_KEYS[i]);
|
||||
frame[pos++] = key & 0xFF;
|
||||
frame[pos++] = key >> 8;
|
||||
if (command == PARAMETERS_WRITE_CMD) {
|
||||
const uint32_t value = this->parameters_[i]; // a 32-bit field on the wire
|
||||
for (uint8_t shift = 0; shift < 32; shift += 8) {
|
||||
frame[pos++] = (value >> shift) & 0xFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -252,27 +356,27 @@ bool LD2410SComponent::handle_frame_() {
|
||||
}
|
||||
|
||||
bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) {
|
||||
uint8_t state;
|
||||
if (this->frame_type_ == FrameType::SHORT_DATA) {
|
||||
// [state][distance low][distance high]
|
||||
state = payload[0];
|
||||
} else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) {
|
||||
// [type][state][distance low][distance high]...; the module only sends these before the init
|
||||
// sequence switches it to minimal output
|
||||
state = payload[1];
|
||||
} else {
|
||||
if (len >= 1) {
|
||||
ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]);
|
||||
// Short frames carry [state][distance low][distance high]; standard frames put a type byte first, and the
|
||||
// module only sends them before the init sequence switches it to minimal output
|
||||
const uint8_t *data = payload;
|
||||
if (this->frame_type_ == FrameType::STD_DATA) {
|
||||
if (len < 4 || payload[0] != DATA_TYPE_STANDARD) {
|
||||
if (len >= 1) {
|
||||
ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
data = payload + 1;
|
||||
}
|
||||
const uint8_t state = data[0];
|
||||
const uint16_t distance = encode_uint16(data[2], data[1]);
|
||||
// States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to
|
||||
// carry the header and footer bytes
|
||||
if (state > 3) {
|
||||
ESP_LOGV(TAG, "Dropping frame with impossible state %u", state);
|
||||
return false;
|
||||
}
|
||||
this->publish_target_(state > 1);
|
||||
this->publish_target_(state > 1, distance);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -283,32 +387,147 @@ void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len)
|
||||
}
|
||||
const uint16_t command_word = encode_uint16(payload[1], payload[0]);
|
||||
const uint16_t ack = encode_uint16(payload[3], payload[2]);
|
||||
if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) {
|
||||
if (!this->awaiting_ack_) {
|
||||
ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word);
|
||||
return;
|
||||
}
|
||||
if (ack != 0) {
|
||||
// Leave the command pending so the timeout resends it
|
||||
ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack);
|
||||
const uint16_t command = this->current_command_();
|
||||
if (command_word != (command | CMD_CONFIRMATION)) {
|
||||
ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word);
|
||||
return;
|
||||
}
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
if (ack != 0 && command == PARAMETERS_WRITE_CMD) {
|
||||
// Resending the same values would be rejected again; leave configuration mode and read the
|
||||
// module's own values back, which republishes them
|
||||
ESP_LOGW(TAG, "Module rejected the parameters, ack %04X", ack);
|
||||
this->send_command_(CONFIG_MODE_END_CMD);
|
||||
this->restart_init_(App.get_loop_component_start_time() + COMMAND_GAP_MS);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (ack != 0) {
|
||||
// Leave the command pending so the timeout resends it
|
||||
ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", command, ack);
|
||||
return;
|
||||
}
|
||||
// A short answer leaves the read pending the same way, so init cannot finish without the values
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
if (command == PARAMETERS_READ_CMD && !this->handle_parameters_(&payload[4], len - 4))
|
||||
return;
|
||||
#endif
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
if (command == VERSION_READ_CMD && !this->handle_version_(&payload[4], len - 4))
|
||||
return;
|
||||
#endif
|
||||
this->awaiting_ack_ = false;
|
||||
this->init_timeouts_ = 0;
|
||||
this->init_step_++;
|
||||
this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS;
|
||||
if (this->init_step_ >= INIT_SEQUENCE_LENGTH) {
|
||||
this->step_timeouts_ = 0;
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->next_send_at_ = now + COMMAND_GAP_MS;
|
||||
if (++this->step_ < this->sequence_len_) {
|
||||
return;
|
||||
}
|
||||
const bool was_init = this->sequence_ == INIT_SEQUENCE;
|
||||
this->sequence_ = nullptr;
|
||||
if (was_init) {
|
||||
ESP_LOGD(TAG, "Setup done");
|
||||
this->last_frame_at_ = App.get_loop_component_start_time();
|
||||
this->init_done_ = true;
|
||||
this->last_frame_at_ = now;
|
||||
this->status_clear_warning();
|
||||
}
|
||||
}
|
||||
|
||||
void LD2410SComponent::publish_target_(bool target) {
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
// The answer lists the parameter values in key order, 32 bits each; returns false when it is cut short
|
||||
bool LD2410SComponent::handle_parameters_(const uint8_t *values, uint16_t len) {
|
||||
if (len < PARAMETER_COUNT * PARAMETER_VALUE_SIZE) {
|
||||
ESP_LOGW(TAG, "Parameter answer too short (%u bytes)", len);
|
||||
return false;
|
||||
}
|
||||
// Every parameter must fit the low byte of its little-endian value; otherwise nothing is stored or
|
||||
// published and writes stay refused, so a cut-down value is never shown or sent back
|
||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||
const uint8_t *v = &values[i * PARAMETER_VALUE_SIZE];
|
||||
if ((v[1] | v[2] | v[3]) != 0) {
|
||||
ESP_LOGW(TAG, "Parameter %u out of range: %" PRIu32 ", changes stay disabled", i,
|
||||
encode_uint32(v[3], v[2], v[1], v[0]));
|
||||
this->parameters_known_ = false;
|
||||
#ifdef USE_NUMBER
|
||||
// A value a number showed before the module answered is not confirmed
|
||||
for (auto *number : this->numbers_) {
|
||||
if (number != nullptr) {
|
||||
number->publish_state(NAN);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||
this->parameters_[i] = values[i * PARAMETER_VALUE_SIZE];
|
||||
}
|
||||
this->parameters_known_ = true;
|
||||
this->publish_parameters_();
|
||||
ESP_LOGD(TAG, "Parameters: gates %u to %u, no delay %u s, frequencies %u/%u dHz, response speed %u",
|
||||
this->parameters_[1], this->parameters_[0], this->parameters_[2], this->parameters_[3], this->parameters_[4],
|
||||
this->parameters_[5]);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
// Returns false when the answer is cut short
|
||||
bool LD2410SComponent::handle_version_(const uint8_t *values, uint16_t len) {
|
||||
if (len < VERSION_SIZE) {
|
||||
ESP_LOGW(TAG, "Version answer too short (%u bytes)", len);
|
||||
return false;
|
||||
}
|
||||
const uint8_t *v = &values[VERSION_TYPE_SIZE];
|
||||
char version[18];
|
||||
snprintf(version, sizeof(version), "%u.%u.%u", encode_uint16(v[1], v[0]), encode_uint16(v[3], v[2]),
|
||||
encode_uint16(v[5], v[4]));
|
||||
ESP_LOGD(TAG, "Firmware %s", version);
|
||||
if (this->version_text_sensor_ != nullptr) {
|
||||
this->version_text_sensor_->publish_state(version);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void LD2410SComponent::publish_target_(bool target, uint16_t distance) {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->target_binary_sensor_ != nullptr) {
|
||||
this->target_binary_sensor_->publish_state(target);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
// The module keeps reporting the last distance after the target left
|
||||
SAFE_PUBLISH_SENSOR(this->distance_sensor_, target ? distance : 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
void LD2410SComponent::publish_parameters_() {
|
||||
#ifdef USE_NUMBER
|
||||
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
|
||||
if (this->numbers_[i] != nullptr) {
|
||||
this->numbers_[i]->publish_raw(this->parameters_[i]);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
if (this->response_speed_select_ != nullptr) {
|
||||
const uint8_t speed = this->parameters_[static_cast<uint8_t>(Parameter::PARAMETER_RESPONSE_SPEED)];
|
||||
for (uint8_t i = 0; i < RESPONSE_SPEED_COUNT; i++) {
|
||||
if (progmem_read_byte(&RESPONSE_SPEEDS[i]) == speed) {
|
||||
this->response_speed_select_->publish_state(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "Unknown response speed %u", speed);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::ld2410s
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/ld24xx/ld24xx.h"
|
||||
#endif
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#endif
|
||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||
#include "esphome/components/select/select.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ld2410s {
|
||||
|
||||
@@ -13,16 +24,55 @@ namespace esphome::ld2410s {
|
||||
static constexpr size_t RX_BUFFER_SIZE = 96;
|
||||
static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte");
|
||||
|
||||
/// The module's common parameters, in the order the parameter read answers them
|
||||
enum class Parameter : uint8_t {
|
||||
PARAMETER_MAX_DISTANCE,
|
||||
PARAMETER_MIN_DISTANCE,
|
||||
PARAMETER_NO_DELAY,
|
||||
PARAMETER_STATUS_FREQUENCY,
|
||||
PARAMETER_DISTANCE_FREQUENCY,
|
||||
PARAMETER_RESPONSE_SPEED,
|
||||
PARAMETER_COUNT,
|
||||
};
|
||||
static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAMETER_COUNT);
|
||||
// Response speed values, in the order of the select's options (Normal, Fast)
|
||||
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
|
||||
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
|
||||
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
class LD2410SNumber;
|
||||
#endif
|
||||
|
||||
class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
SUB_BINARY_SENSOR(target)
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
SUB_SENSOR_WITH_DEDUP(distance, uint16_t)
|
||||
#endif
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
SUB_TEXT_SENSOR(version)
|
||||
#endif
|
||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||
SUB_SELECT(response_speed)
|
||||
#endif
|
||||
|
||||
public:
|
||||
void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Store a parameter in the module's units; loop() then writes all of them to the module.
|
||||
/// Returns false, and changes nothing, until the module's own values have been read.
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
bool set_parameter(Parameter parameter, uint8_t value);
|
||||
#ifdef USE_NUMBER
|
||||
void set_number(Parameter parameter, LD2410SNumber *number) {
|
||||
this->numbers_[static_cast<uint8_t>(parameter)] = number;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
protected:
|
||||
enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND };
|
||||
|
||||
@@ -32,6 +82,12 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||
/// Returns false for a data frame whose state byte is impossible, so it counts as dropped.
|
||||
bool handle_data_frame_(const uint8_t *payload, uint16_t len);
|
||||
void handle_command_ack_(const uint8_t *payload, uint16_t len);
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
bool handle_parameters_(const uint8_t *values, uint16_t len);
|
||||
#endif
|
||||
#ifdef USE_LD2410S_VERSION
|
||||
bool handle_version_(const uint8_t *values, uint16_t len);
|
||||
#endif
|
||||
void count_dropped_frame_() {
|
||||
if (this->dropped_frames_ < UINT8_MAX) {
|
||||
this->dropped_frames_++;
|
||||
@@ -42,21 +98,41 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
|
||||
this->expected_len_ = 0;
|
||||
this->frame_type_ = FrameType::NONE;
|
||||
}
|
||||
void run_init_sequence_(uint32_t now);
|
||||
void start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now);
|
||||
void restart_init_(uint32_t at);
|
||||
void run_sequence_(uint32_t now);
|
||||
uint16_t current_command_() const;
|
||||
void send_command_(uint16_t command);
|
||||
void publish_target_(bool target);
|
||||
void publish_target_(bool target, uint16_t distance);
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
void publish_parameters_();
|
||||
#endif
|
||||
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
|
||||
#endif
|
||||
|
||||
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
|
||||
uint32_t next_send_at_{0};
|
||||
uint32_t last_frame_at_{0};
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
uint8_t parameters_[PARAMETER_COUNT]{}; // all fit a byte: gates, seconds, tenths of a hertz, speed
|
||||
#endif
|
||||
uint8_t rx_buffer_[RX_BUFFER_SIZE];
|
||||
uint8_t rx_len_{0};
|
||||
uint8_t expected_len_{0};
|
||||
uint8_t init_step_{0};
|
||||
uint8_t init_timeouts_{0};
|
||||
uint8_t sequence_len_{0};
|
||||
uint8_t step_{0};
|
||||
uint8_t step_timeouts_{0};
|
||||
uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255
|
||||
FrameType frame_type_{FrameType::NONE};
|
||||
bool awaiting_ack_{false};
|
||||
bool rescanning_{false};
|
||||
bool init_done_{false};
|
||||
#ifdef USE_LD2410S_PARAMETERS
|
||||
bool parameters_known_{false}; // the module answered the parameter read at least once
|
||||
bool parameters_dirty_{false};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::ld2410s
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
|
||||
|
||||
#include "ld2410s.h"
|
||||
#include "esphome/components/number/number.h"
|
||||
|
||||
namespace esphome::ld2410s {
|
||||
|
||||
/// Edits one common parameter; scale converts the module's units to the number's.
|
||||
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
|
||||
public:
|
||||
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
|
||||
|
||||
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
|
||||
|
||||
protected:
|
||||
void control(float value) override {
|
||||
// Show the step the module got, not a value between two steps
|
||||
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
|
||||
if (this->parent_->set_parameter(this->parameter_, raw))
|
||||
this->publish_raw(raw);
|
||||
}
|
||||
|
||||
const Parameter parameter_;
|
||||
const float scale_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ld2410s
|
||||
|
||||
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
|
||||
|
||||
#include "ld2410s.h"
|
||||
#include "esphome/components/select/select.h"
|
||||
|
||||
namespace esphome::ld2410s {
|
||||
|
||||
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
|
||||
protected:
|
||||
void control(size_t index) override {
|
||||
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
|
||||
this->publish_state(index);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::ld2410s
|
||||
|
||||
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
|
||||
@@ -0,0 +1,108 @@
|
||||
from functools import partial
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_DISTANCE,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_FREQUENCY,
|
||||
ENTITY_CATEGORY_CONFIG,
|
||||
ICON_PULSE,
|
||||
ICON_TIMELAPSE,
|
||||
UNIT_HERTZ,
|
||||
UNIT_METER,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
|
||||
|
||||
DEPENDENCIES = ["ld2410s"]
|
||||
|
||||
CONF_DISTANCE_REPORTING_FREQUENCY = "distance_reporting_frequency"
|
||||
CONF_MAX_DISTANCE = "max_distance"
|
||||
CONF_MIN_DISTANCE = "min_distance"
|
||||
CONF_NO_DELAY = "no_delay"
|
||||
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
|
||||
|
||||
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
|
||||
|
||||
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
|
||||
GATE_WIDTH_M = 0.7
|
||||
FREQUENCY_SCALE = 0.1
|
||||
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
|
||||
_FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
|
||||
|
||||
|
||||
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
|
||||
return number.number_schema(
|
||||
LD2410SNumber,
|
||||
device_class=device_class,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
unit_of_measurement=unit,
|
||||
icon=icon,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
||||
cv.Optional(CONF_MAX_DISTANCE): _number_schema(
|
||||
DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-right"
|
||||
),
|
||||
cv.Optional(CONF_MIN_DISTANCE): _number_schema(
|
||||
DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-left"
|
||||
),
|
||||
cv.Optional(CONF_NO_DELAY): _number_schema(
|
||||
DEVICE_CLASS_DURATION, UNIT_SECOND, ICON_TIMELAPSE
|
||||
),
|
||||
cv.Optional(CONF_STATUS_REPORTING_FREQUENCY): _number_schema(
|
||||
DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE
|
||||
),
|
||||
cv.Optional(CONF_DISTANCE_REPORTING_FREQUENCY): _number_schema(
|
||||
DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_LD2410S_PARAMETERS")
|
||||
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
||||
numbers = number.sub_numbers(config, parent=hub)
|
||||
await numbers(
|
||||
CONF_MAX_DISTANCE,
|
||||
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
|
||||
Parameter.PARAMETER_MAX_DISTANCE,
|
||||
GATE_WIDTH_M,
|
||||
**_DISTANCE_RANGE,
|
||||
)
|
||||
await numbers(
|
||||
CONF_MIN_DISTANCE,
|
||||
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
|
||||
Parameter.PARAMETER_MIN_DISTANCE,
|
||||
GATE_WIDTH_M,
|
||||
**_DISTANCE_RANGE,
|
||||
)
|
||||
await numbers(
|
||||
CONF_NO_DELAY,
|
||||
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
|
||||
Parameter.PARAMETER_NO_DELAY,
|
||||
1,
|
||||
min_value=1,
|
||||
max_value=120,
|
||||
step=1,
|
||||
)
|
||||
await numbers(
|
||||
CONF_STATUS_REPORTING_FREQUENCY,
|
||||
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
|
||||
Parameter.PARAMETER_STATUS_FREQUENCY,
|
||||
FREQUENCY_SCALE,
|
||||
**_FREQUENCY_RANGE,
|
||||
)
|
||||
await numbers(
|
||||
CONF_DISTANCE_REPORTING_FREQUENCY,
|
||||
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
|
||||
Parameter.PARAMETER_DISTANCE_FREQUENCY,
|
||||
FREQUENCY_SCALE,
|
||||
**_FREQUENCY_RANGE,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
|
||||
|
||||
DEPENDENCIES = ["ld2410s"]
|
||||
|
||||
CONF_RESPONSE_SPEED = "response_speed"
|
||||
|
||||
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
||||
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
|
||||
LD2410SSelect,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon="mdi:speedometer",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_LD2410S_PARAMETERS")
|
||||
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
||||
selects = select.sub_selects(config, parent=hub)
|
||||
await selects(
|
||||
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DISTANCE,
|
||||
DEVICE_CLASS_DISTANCE,
|
||||
ICON_SIGNAL_DISTANCE_VARIANT,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CENTIMETER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent
|
||||
|
||||
DEPENDENCIES = ["ld2410s"]
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
||||
cv.Required(CONF_DISTANCE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CENTIMETER,
|
||||
icon=ICON_SIGNAL_DISTANCE_VARIANT,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_DISTANCE, hub.set_distance_sensor)
|
||||
@@ -0,0 +1,23 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_LD2410S_ID, LD2410SComponent
|
||||
|
||||
DEPENDENCIES = ["ld2410s"]
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
|
||||
cv.Required(CONF_VERSION): text_sensor.text_sensor_schema(
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_LD2410S_VERSION")
|
||||
hub = await cg.get_variable(config[CONF_LD2410S_ID])
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
await text_sensors(CONF_VERSION, hub.set_version_text_sensor)
|
||||
@@ -25,6 +25,7 @@ from esphome.components.image import (
|
||||
ImageBinary,
|
||||
ImageGrayscale,
|
||||
ImageRGB,
|
||||
ImageRGB111,
|
||||
ImageRGB565,
|
||||
get_image_metadata,
|
||||
)
|
||||
@@ -43,6 +44,7 @@ from esphome.const import (
|
||||
CONF_ROTATION,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE, ID, Lambda
|
||||
from esphome.cpp_generator import MockObj
|
||||
@@ -293,6 +295,21 @@ def final_validation(config_list):
|
||||
if len(config_list) != 1:
|
||||
multi_conf_validate(config_list)
|
||||
|
||||
for img in get_lv_images_used():
|
||||
path = global_config.get_path_for_id(img.id)[:-1]
|
||||
# An image nested in another platform (sendspin's current_image) takes its type from the parent
|
||||
while path:
|
||||
image_conf = global_config.get_config_for_path(path)
|
||||
if isinstance(image_conf, dict) and CONF_TYPE in image_conf:
|
||||
break
|
||||
path = path[:-1]
|
||||
else:
|
||||
continue
|
||||
if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111:
|
||||
raise cv.FinalExternalInvalid(
|
||||
f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead",
|
||||
path + [CONF_TYPE],
|
||||
)
|
||||
for w in get_focused_widgets():
|
||||
path = global_config.get_path_for_id(w)
|
||||
widget_conf = global_config.get_config_for_path(path[:-1])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user