mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
Merge remote-tracking branch 'origin/dev' into store-yaml-firmware
This commit is contained in:
@@ -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()`)
|
||||
@@ -202,6 +205,24 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
|
||||
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
|
||||
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
|
||||
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
|
||||
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
|
||||
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
|
||||
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
|
||||
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
|
||||
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
|
||||
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
|
||||
resend and give-up logic handles it; never advance on an answer that was only logged.
|
||||
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
|
||||
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
|
||||
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
|
||||
@@ -552,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)`.
|
||||
@@ -589,9 +613,14 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
|
||||
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
|
||||
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`).
|
||||
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
|
||||
`responses` match on content every time they see it; to answer a repeated request differently, use an
|
||||
`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/
|
||||
|
||||
+10
@@ -39,10 +39,12 @@ esphome/components/am43/sensor/* @buxtronix
|
||||
esphome/components/analog_threshold/* @ianchi
|
||||
esphome/components/animation/* @syndlex
|
||||
esphome/components/anova/* @buxtronix
|
||||
esphome/components/apc1/* @ademuri
|
||||
esphome/components/apds9306/* @aodrenah
|
||||
esphome/components/apds9930/* @iyesin
|
||||
esphome/components/api/* @esphome/core
|
||||
esphome/components/aqi/* @freekode @jasstrong @ximex
|
||||
esphome/components/aranet/* @patrickod
|
||||
esphome/components/as5600/* @ammmze
|
||||
esphome/components/as5600/sensor/* @ammmze
|
||||
esphome/components/as7341/* @mrgnr
|
||||
@@ -104,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
|
||||
@@ -209,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
|
||||
@@ -238,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
|
||||
@@ -282,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
|
||||
@@ -430,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
|
||||
|
||||
+29
-14
@@ -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
|
||||
@@ -1376,6 +1376,7 @@ def _upload_via_native_api(
|
||||
# Fail closed: an encryption block whose key did not resolve must never
|
||||
# fall back to a plaintext upload
|
||||
noise_psk = None
|
||||
old_noise_psk = None
|
||||
plaintext_fallback = False
|
||||
allow_plaintext_upload = False
|
||||
if ota_key := getattr(args, "ota_key", None):
|
||||
@@ -1396,6 +1397,8 @@ def _upload_via_native_api(
|
||||
# Ensure the key is a string, as required by the underlying OTA implementation.
|
||||
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
|
||||
noise_psk = str(noise_psk)
|
||||
if old_key := encryption_conf.get(espota2.CONF_OLD_KEY):
|
||||
old_noise_psk = str(old_key)
|
||||
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
|
||||
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
|
||||
noise_psk = str(api_key)
|
||||
@@ -1445,19 +1448,30 @@ def _upload_via_native_api(
|
||||
noise_psk,
|
||||
plaintext_fallback=plaintext_fallback,
|
||||
allow_plaintext_upload=allow_plaintext_upload,
|
||||
old_noise_psk=old_noise_psk,
|
||||
)
|
||||
|
||||
|
||||
def _old_key_notice(config: ConfigType) -> None:
|
||||
"""After `run`, whose image was just built from this config; a plain
|
||||
`upload` may send a stale image that still carries the previous key."""
|
||||
from esphome import espota2
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 "
|
||||
@@ -1892,6 +1906,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
exit_code, successful_device = upload_program(config, args, devices)
|
||||
if exit_code == 0:
|
||||
_LOGGER.info("Successfully uploaded program.")
|
||||
_old_key_notice(config)
|
||||
else:
|
||||
_LOGGER.warning("Failed to upload to %s", devices)
|
||||
return exit_code
|
||||
|
||||
+37
-7
@@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str:
|
||||
return str(cg.progmem_string(value))
|
||||
|
||||
|
||||
def flash_compare(config: ConfigType, value: str) -> str:
|
||||
"""Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266."""
|
||||
if CORE.is_esp8266:
|
||||
return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}"
|
||||
return str(cg.safe_exp(value))
|
||||
|
||||
|
||||
def _default_std_string(
|
||||
members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str]
|
||||
) -> list[tuple[str, Any, Any]]:
|
||||
"""Give ``cg.std_string`` members without a renderer the given default."""
|
||||
return [
|
||||
(key, t, fn or (renderer if t is cg.std_string else None))
|
||||
for key, t, fn in members
|
||||
]
|
||||
|
||||
|
||||
def literal_with_length(config: ConfigType, value: str) -> str:
|
||||
"""Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length.
|
||||
|
||||
@@ -491,10 +508,7 @@ def apply_action_builder(
|
||||
statements_spec = [
|
||||
(
|
||||
c.target,
|
||||
[
|
||||
(key, t, fn or (flash_string if t is cg.std_string else None))
|
||||
for key, t, fn in c.members
|
||||
],
|
||||
_default_std_string(c.members, flash_string),
|
||||
)
|
||||
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
|
||||
]
|
||||
@@ -563,11 +577,12 @@ def register_apply_condition(
|
||||
``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an
|
||||
``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares
|
||||
against config values, all of which must be present. Write ``== false`` to negate.
|
||||
String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member.
|
||||
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
|
||||
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
|
||||
Generates one static predicate and an ``ApplyCondition`` templated on it.
|
||||
"""
|
||||
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
|
||||
members = call.members
|
||||
members = _default_std_string(call.members, flash_compare)
|
||||
_check_key_in_schema(name, schema, id_key)
|
||||
for conf_key, _, _ in members:
|
||||
_check_key_in_schema(name, schema, conf_key)
|
||||
@@ -891,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,
|
||||
@@ -914,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))
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Support for ScioSense APC1 air quality sensor."""
|
||||
|
||||
DOMAIN = "apc1"
|
||||
@@ -0,0 +1,352 @@
|
||||
#include "apc1.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::apc1 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "apc1");
|
||||
|
||||
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58;
|
||||
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61;
|
||||
|
||||
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4;
|
||||
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10;
|
||||
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19;
|
||||
|
||||
// Give a freshly powered sensor time to boot before the first retry
|
||||
static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000;
|
||||
// Re-send the init commands at most this often while no frames arrive
|
||||
static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000;
|
||||
// Active mode streams a frame every second; this long without one means the sensor stopped
|
||||
static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000;
|
||||
// Drop a partial frame when the bytes stop mid-way
|
||||
static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200;
|
||||
|
||||
// Frames and commands end with the 16-bit sum of every byte before it
|
||||
static uint16_t apc1_checksum(const uint8_t *data, size_t len) {
|
||||
uint16_t sum = 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
sum += data[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
void APC1Component::setup() {
|
||||
if (this->set_pin_ != nullptr) {
|
||||
this->set_pin_->setup();
|
||||
this->set_pin_->digital_write(true);
|
||||
}
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
// Attempt initial communication immediately for warm restarts
|
||||
this->initialize_device_();
|
||||
|
||||
// Schedule delayed initialization to catch sensors during cold power-on bootup
|
||||
this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); });
|
||||
}
|
||||
|
||||
void APC1Component::initialize_device_() {
|
||||
this->last_init_attempt_ = App.get_loop_component_start_time();
|
||||
if (this->active_mode_ && !this->idle_) {
|
||||
this->set_active_mode();
|
||||
}
|
||||
this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000);
|
||||
}
|
||||
|
||||
void APC1Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "APC1:");
|
||||
LOG_PIN(" Set Pin: ", this->set_pin_);
|
||||
LOG_PIN(" Reset Pin: ", this->reset_pin_);
|
||||
LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_);
|
||||
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
|
||||
LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_);
|
||||
LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_);
|
||||
LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_);
|
||||
LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_);
|
||||
LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_);
|
||||
LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_);
|
||||
LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_);
|
||||
LOG_SENSOR(" ", "eCO2", this->eco2_sensor_);
|
||||
LOG_SENSOR(" ", "AQI", this->aqi_sensor_);
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
|
||||
LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_);
|
||||
LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_);
|
||||
LOG_SENSOR(" ", "RS0", this->rs0_sensor_);
|
||||
LOG_SENSOR(" ", "RS2", this->rs2_sensor_);
|
||||
LOG_SENSOR(" ", "RS3", this->rs3_sensor_);
|
||||
LOG_SENSOR(" ", "Error Code", this->error_code_sensor_);
|
||||
}
|
||||
|
||||
void APC1Component::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
// If in active mode and not idle, retry periodically if no valid data received
|
||||
if (this->active_mode_ && !this->idle_) {
|
||||
if (this->last_valid_frame_ == 0) {
|
||||
if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
|
||||
ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands...");
|
||||
this->initialize_device_();
|
||||
}
|
||||
} else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS &&
|
||||
now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
|
||||
ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_);
|
||||
this->initialize_device_();
|
||||
}
|
||||
}
|
||||
|
||||
if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) {
|
||||
ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer");
|
||||
this->rx_index_ = 0;
|
||||
}
|
||||
|
||||
while (this->available()) {
|
||||
this->last_transmission_ = now;
|
||||
uint8_t byte;
|
||||
this->read_byte(&byte);
|
||||
|
||||
if (this->rx_index_ == 0) {
|
||||
if (byte == APC1_HEADER_1) {
|
||||
this->rx_buffer_[this->rx_index_++] = byte;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this->rx_index_ == 1) {
|
||||
if (byte == APC1_HEADER_2) {
|
||||
this->rx_buffer_[this->rx_index_++] = byte;
|
||||
} else if (byte != APC1_HEADER_1) {
|
||||
this->rx_index_ = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_index_++] = byte;
|
||||
if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a
|
||||
// corrupt length from wrapping the total and walking past the buffer
|
||||
const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]);
|
||||
if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) {
|
||||
ESP_LOGW(TAG, "Invalid frame length: %u", payload_len);
|
||||
this->rx_index_ = 0;
|
||||
continue;
|
||||
}
|
||||
const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE;
|
||||
if (this->rx_index_ == total_len) {
|
||||
this->parse_frame_(total_len);
|
||||
this->rx_index_ = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void APC1Component::parse_frame_(uint16_t total_len) {
|
||||
uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]);
|
||||
uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2);
|
||||
if (expected_checksum != calculated_checksum) {
|
||||
ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum,
|
||||
expected_checksum);
|
||||
return;
|
||||
}
|
||||
|
||||
if (total_len == APC1_FRAME_SIZE_MEASUREMENT) {
|
||||
this->parse_measurement_frame_();
|
||||
} else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) {
|
||||
this->parse_device_info_frame_();
|
||||
} else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) {
|
||||
ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Received frame of unknown length %u", total_len);
|
||||
}
|
||||
}
|
||||
|
||||
// One message per error bit, bit 0 first
|
||||
PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault",
|
||||
"Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault");
|
||||
|
||||
void APC1Component::parse_measurement_frame_() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->last_valid_frame_ = now;
|
||||
|
||||
uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE];
|
||||
if (error_code != this->last_error_code_) {
|
||||
if (error_code != 0) {
|
||||
for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) {
|
||||
if (error_code & (1 << bit)) {
|
||||
ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX)));
|
||||
}
|
||||
}
|
||||
this->status_set_warning(LOG_STR("Sensor reported hardware error"));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "APC1: Hardware error cleared");
|
||||
this->status_clear_warning();
|
||||
}
|
||||
this->last_error_code_ = error_code;
|
||||
if (this->error_code_sensor_ != nullptr) {
|
||||
this->error_code_sensor_->publish_state(error_code);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI];
|
||||
if (aqi == 0) {
|
||||
if (!this->gas_warming_up_) {
|
||||
this->gas_warming_up_ = true;
|
||||
ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)");
|
||||
}
|
||||
} else if (this->gas_warming_up_) {
|
||||
this->gas_warming_up_ = false;
|
||||
ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi);
|
||||
}
|
||||
|
||||
// Measurement payload data (bytes 4-57): big-endian sensor readings.
|
||||
// The values below feed the debug summary, so they are always decoded; every
|
||||
// other reading is only decoded when its sensor is configured.
|
||||
const auto u16 = [this](uint8_t offset) {
|
||||
return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]);
|
||||
};
|
||||
const auto u32 = [this](uint8_t offset) {
|
||||
return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2],
|
||||
this->rx_buffer_[offset + 3]);
|
||||
};
|
||||
uint16_t pm_1_0 = u16(10);
|
||||
uint16_t pm_2_5 = u16(12);
|
||||
uint16_t pm_10_0 = u16(14);
|
||||
uint16_t tvoc = u16(28);
|
||||
uint16_t eco2 = u16(30);
|
||||
float temp_comp = static_cast<int16_t>(u16(34)) / 10.0f;
|
||||
float hum_comp = u16(36) / 10.0f;
|
||||
|
||||
ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0,
|
||||
pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp);
|
||||
|
||||
if (this->pm_1_0_std_sensor_ != nullptr)
|
||||
this->pm_1_0_std_sensor_->publish_state(u16(4));
|
||||
if (this->pm_2_5_std_sensor_ != nullptr)
|
||||
this->pm_2_5_std_sensor_->publish_state(u16(6));
|
||||
if (this->pm_10_0_std_sensor_ != nullptr)
|
||||
this->pm_10_0_std_sensor_->publish_state(u16(8));
|
||||
|
||||
if (this->pm_1_0_sensor_ != nullptr)
|
||||
this->pm_1_0_sensor_->publish_state(pm_1_0);
|
||||
if (this->pm_2_5_sensor_ != nullptr)
|
||||
this->pm_2_5_sensor_->publish_state(pm_2_5);
|
||||
if (this->pm_10_0_sensor_ != nullptr)
|
||||
this->pm_10_0_sensor_->publish_state(pm_10_0);
|
||||
|
||||
if (this->pm_0_3um_sensor_ != nullptr)
|
||||
this->pm_0_3um_sensor_->publish_state(u16(16));
|
||||
if (this->pm_0_5um_sensor_ != nullptr)
|
||||
this->pm_0_5um_sensor_->publish_state(u16(18));
|
||||
if (this->pm_1_0um_sensor_ != nullptr)
|
||||
this->pm_1_0um_sensor_->publish_state(u16(20));
|
||||
if (this->pm_2_5um_sensor_ != nullptr)
|
||||
this->pm_2_5um_sensor_->publish_state(u16(22));
|
||||
if (this->pm_5_0um_sensor_ != nullptr)
|
||||
this->pm_5_0um_sensor_->publish_state(u16(24));
|
||||
if (this->pm_10_0um_sensor_ != nullptr)
|
||||
this->pm_10_0um_sensor_->publish_state(u16(26));
|
||||
|
||||
if (this->tvoc_sensor_ != nullptr)
|
||||
this->tvoc_sensor_->publish_state(tvoc);
|
||||
if (this->eco2_sensor_ != nullptr)
|
||||
this->eco2_sensor_->publish_state(eco2);
|
||||
if (this->aqi_sensor_ != nullptr)
|
||||
this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN);
|
||||
|
||||
if (this->temperature_sensor_ != nullptr)
|
||||
this->temperature_sensor_->publish_state(temp_comp);
|
||||
if (this->humidity_sensor_ != nullptr)
|
||||
this->humidity_sensor_->publish_state(hum_comp);
|
||||
|
||||
if (this->raw_temperature_sensor_ != nullptr) {
|
||||
this->raw_temperature_sensor_->publish_state(static_cast<int16_t>(u16(38)) / 10.0f);
|
||||
}
|
||||
if (this->raw_humidity_sensor_ != nullptr)
|
||||
this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f);
|
||||
|
||||
if (this->rs0_sensor_ != nullptr) {
|
||||
this->rs0_sensor_->publish_state(u32(42));
|
||||
}
|
||||
if (this->rs2_sensor_ != nullptr) {
|
||||
this->rs2_sensor_->publish_state(u32(50));
|
||||
}
|
||||
if (this->rs3_sensor_ != nullptr) {
|
||||
this->rs3_sensor_->publish_state(u32(54));
|
||||
}
|
||||
}
|
||||
|
||||
void APC1Component::parse_device_info_frame_() {
|
||||
const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL;
|
||||
uint16_t fw_version =
|
||||
encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]);
|
||||
ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)",
|
||||
reinterpret_cast<const char *>(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE),
|
||||
encode_uint32(serial[0], serial[1], serial[2], serial[3]),
|
||||
encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version);
|
||||
}
|
||||
|
||||
void APC1Component::arm_watchdog_() {
|
||||
this->last_valid_frame_ = App.get_loop_component_start_time();
|
||||
this->last_init_attempt_ = this->last_valid_frame_;
|
||||
}
|
||||
|
||||
void APC1Component::set_active_mode() {
|
||||
if (!this->active_mode_) {
|
||||
this->arm_watchdog_();
|
||||
}
|
||||
this->active_mode_ = true;
|
||||
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE);
|
||||
}
|
||||
|
||||
void APC1Component::set_passive_mode() {
|
||||
this->active_mode_ = false;
|
||||
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE);
|
||||
}
|
||||
|
||||
void APC1Component::request_measurement() {
|
||||
this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000);
|
||||
}
|
||||
|
||||
void APC1Component::set_idle_mode() {
|
||||
this->idle_ = true;
|
||||
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE);
|
||||
}
|
||||
|
||||
void APC1Component::set_measurement_mode() {
|
||||
this->idle_ = false;
|
||||
this->arm_watchdog_();
|
||||
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD);
|
||||
}
|
||||
|
||||
void APC1Component::send_command_(APC1Command cmd, uint16_t data) {
|
||||
std::array<uint8_t, APC1_COMMAND_FRAME_SIZE> buf{
|
||||
APC1_HEADER_1,
|
||||
APC1_HEADER_2,
|
||||
static_cast<uint8_t>(cmd),
|
||||
static_cast<uint8_t>((data >> 8) & 0xFF),
|
||||
static_cast<uint8_t>(data & 0xFF),
|
||||
0,
|
||||
0,
|
||||
};
|
||||
uint16_t checksum = apc1_checksum(buf.data(), 5);
|
||||
buf[5] = static_cast<uint8_t>((checksum >> 8) & 0xFF);
|
||||
buf[6] = static_cast<uint8_t>(checksum & 0xFF);
|
||||
this->write_array(buf);
|
||||
}
|
||||
|
||||
} // namespace esphome::apc1
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::apc1 {
|
||||
|
||||
static constexpr uint8_t APC1_HEADER_1 = 0x42;
|
||||
static constexpr uint8_t APC1_HEADER_2 = 0x4D;
|
||||
static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes
|
||||
static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64;
|
||||
static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23;
|
||||
static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8;
|
||||
static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7;
|
||||
|
||||
enum class APC1Command : uint8_t {
|
||||
APC1_COMMAND_MEASUREMENT_MODE = 0xE1,
|
||||
APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2,
|
||||
APC1_COMMAND_OPERATION_MODE = 0xE4,
|
||||
APC1_COMMAND_READ_SENSOR_VERSION = 0xE9,
|
||||
};
|
||||
|
||||
static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000;
|
||||
static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001;
|
||||
|
||||
static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000;
|
||||
static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001;
|
||||
|
||||
class APC1Component final : public uart::UARTDevice, public Component {
|
||||
SUB_SENSOR(pm_1_0)
|
||||
SUB_SENSOR(pm_2_5)
|
||||
SUB_SENSOR(pm_10_0)
|
||||
SUB_SENSOR(pm_1_0_std)
|
||||
SUB_SENSOR(pm_2_5_std)
|
||||
SUB_SENSOR(pm_10_0_std)
|
||||
|
||||
SUB_SENSOR(pm_0_3um)
|
||||
SUB_SENSOR(pm_0_5um)
|
||||
SUB_SENSOR(pm_1_0um)
|
||||
SUB_SENSOR(pm_2_5um)
|
||||
SUB_SENSOR(pm_5_0um)
|
||||
SUB_SENSOR(pm_10_0um)
|
||||
|
||||
SUB_SENSOR(tvoc)
|
||||
SUB_SENSOR(eco2)
|
||||
SUB_SENSOR(aqi)
|
||||
|
||||
SUB_SENSOR(temperature)
|
||||
SUB_SENSOR(humidity)
|
||||
SUB_SENSOR(raw_temperature)
|
||||
SUB_SENSOR(raw_humidity)
|
||||
|
||||
SUB_SENSOR(rs0)
|
||||
SUB_SENSOR(rs2)
|
||||
SUB_SENSOR(rs3)
|
||||
|
||||
SUB_SENSOR(error_code)
|
||||
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void loop() override;
|
||||
|
||||
void set_active_mode();
|
||||
void set_passive_mode();
|
||||
void request_measurement();
|
||||
void set_idle_mode();
|
||||
void set_measurement_mode();
|
||||
|
||||
void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; }
|
||||
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
|
||||
|
||||
protected:
|
||||
void send_command_(APC1Command cmd, uint16_t data);
|
||||
void initialize_device_();
|
||||
/// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot
|
||||
void arm_watchdog_();
|
||||
void parse_frame_(uint16_t total_len);
|
||||
void parse_measurement_frame_();
|
||||
void parse_device_info_frame_();
|
||||
|
||||
GPIOPin *set_pin_{nullptr};
|
||||
GPIOPin *reset_pin_{nullptr};
|
||||
uint32_t last_transmission_{0};
|
||||
uint32_t last_valid_frame_{0};
|
||||
uint32_t last_init_attempt_{0};
|
||||
std::array<uint8_t, APC1_FRAME_SIZE_MEASUREMENT> rx_buffer_{};
|
||||
uint8_t rx_index_{0};
|
||||
uint8_t last_error_code_{0xFF};
|
||||
bool active_mode_{true};
|
||||
bool idle_{false};
|
||||
bool gas_warming_up_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::apc1
|
||||
@@ -0,0 +1,222 @@
|
||||
from esphome import automation, pins
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor, uart
|
||||
from esphome.components.const import CONF_AQI
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ECO2,
|
||||
CONF_HUMIDITY,
|
||||
CONF_ID,
|
||||
CONF_PM_0_3UM,
|
||||
CONF_PM_0_5UM,
|
||||
CONF_PM_1_0,
|
||||
CONF_PM_1_0_STD,
|
||||
CONF_PM_1_0UM,
|
||||
CONF_PM_2_5,
|
||||
CONF_PM_2_5_STD,
|
||||
CONF_PM_2_5UM,
|
||||
CONF_PM_5_0UM,
|
||||
CONF_PM_10_0,
|
||||
CONF_PM_10_0_STD,
|
||||
CONF_PM_10_0UM,
|
||||
CONF_RESET_PIN,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TVOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
DEVICE_CLASS_PM10,
|
||||
DEVICE_CLASS_PM25,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
ICON_CHEMICAL_WEAPON,
|
||||
ICON_MOLECULE_CO2,
|
||||
ICON_OMEGA,
|
||||
ICON_RADIATOR,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_COUNT_DECILITRE,
|
||||
UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
UNIT_OHM,
|
||||
UNIT_PARTS_PER_BILLION,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@ademuri"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
CONF_SET_PIN = "set_pin"
|
||||
CONF_RAW_TEMPERATURE = "raw_temperature"
|
||||
CONF_RAW_HUMIDITY = "raw_humidity"
|
||||
CONF_RS0 = "rs0"
|
||||
CONF_RS2 = "rs2"
|
||||
CONF_RS3 = "rs3"
|
||||
CONF_ERROR_CODE = "error_code"
|
||||
|
||||
apc1_ns = cg.esphome_ns.namespace("apc1")
|
||||
APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component)
|
||||
|
||||
|
||||
def _pm_mass_schema(device_class: str) -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=0,
|
||||
device_class=device_class,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
|
||||
_PM_COUNT_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_COUNT_DECILITRE,
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
_TEMPERATURE_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
_HUMIDITY_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
_RESISTANCE_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_OHM,
|
||||
icon=ICON_OMEGA,
|
||||
accuracy_decimals=0,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(APC1Component),
|
||||
cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1),
|
||||
cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25),
|
||||
cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10),
|
||||
cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1),
|
||||
cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25),
|
||||
cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10),
|
||||
cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA,
|
||||
cv.Optional(CONF_TVOC): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_BILLION,
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_ECO2): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_MILLION,
|
||||
icon=ICON_MOLECULE_CO2,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_AQI): sensor.sensor_schema(
|
||||
icon=ICON_CHEMICAL_WEAPON,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA,
|
||||
cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA,
|
||||
cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA,
|
||||
cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA,
|
||||
cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA,
|
||||
cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA,
|
||||
cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA,
|
||||
cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema(
|
||||
icon="mdi:alert-circle-outline",
|
||||
accuracy_decimals=0,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config: ConfigType) -> None:
|
||||
schema = uart.final_validate_device_schema(
|
||||
"apc1", baud_rate=9600, require_rx=True, require_tx=True
|
||||
)
|
||||
schema(config)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor)
|
||||
await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor)
|
||||
await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor)
|
||||
await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor)
|
||||
await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor)
|
||||
await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor)
|
||||
await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor)
|
||||
await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor)
|
||||
await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor)
|
||||
await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor)
|
||||
await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor)
|
||||
await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor)
|
||||
await sensors(CONF_TVOC, var.set_tvoc_sensor)
|
||||
await sensors(CONF_ECO2, var.set_eco2_sensor)
|
||||
await sensors(CONF_AQI, var.set_aqi_sensor)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor)
|
||||
await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor)
|
||||
await sensors(CONF_RS0, var.set_rs0_sensor)
|
||||
await sensors(CONF_RS2, var.set_rs2_sensor)
|
||||
await sensors(CONF_RS3, var.set_rs3_sensor)
|
||||
await sensors(CONF_ERROR_CODE, var.set_error_code_sensor)
|
||||
|
||||
if (set_pin_config := config.get(CONF_SET_PIN)) is not None:
|
||||
set_pin = await cg.gpio_pin_expression(set_pin_config)
|
||||
cg.add(var.set_set_pin(set_pin))
|
||||
|
||||
if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None:
|
||||
reset_pin = await cg.gpio_pin_expression(reset_pin_config)
|
||||
cg.add(var.set_reset_pin(reset_pin))
|
||||
|
||||
|
||||
APC1_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(APC1Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
for _name, _call in (
|
||||
("apc1.set_active_mode", "set_active_mode()"),
|
||||
("apc1.set_passive_mode", "set_passive_mode()"),
|
||||
("apc1.request_measurement", "request_measurement()"),
|
||||
("apc1.set_idle_mode", "set_idle_mode()"),
|
||||
("apc1.set_measurement_mode", "set_measurement_mode()"),
|
||||
):
|
||||
automation.register_apply_action(
|
||||
_name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call)
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
CODEOWNERS = ["@patrickod"]
|
||||
DOMAIN = "aranet"
|
||||
@@ -0,0 +1,157 @@
|
||||
#include "aranet.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::aranet {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "aranet");
|
||||
static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702;
|
||||
static constexpr size_t ARANET4_DATA_LENGTH = 22;
|
||||
static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24;
|
||||
|
||||
enum class AranetDeviceType : uint8_t {
|
||||
ARANET_DEVICE_TYPE_ARANET4 = 0,
|
||||
ARANET_DEVICE_TYPE_ARANET2 = 1,
|
||||
ARANET_DEVICE_TYPE_RADIATION = 2,
|
||||
ARANET_DEVICE_TYPE_RADON = 3,
|
||||
};
|
||||
|
||||
static inline void publish_if_set(sensor::Sensor *sensor, float value) {
|
||||
if (sensor != nullptr)
|
||||
sensor->publish_state(value);
|
||||
}
|
||||
|
||||
// Aranet does not publish an official advertisement specification. The layout below is corroborated by these
|
||||
// independently maintained implementations:
|
||||
// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py
|
||||
// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h
|
||||
|
||||
void Aranet::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Aranet");
|
||||
LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_);
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
|
||||
LOG_SENSOR(" ", "Pressure", this->pressure_sensor_);
|
||||
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
|
||||
LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_);
|
||||
LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_);
|
||||
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_);
|
||||
LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_);
|
||||
LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_);
|
||||
LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_);
|
||||
LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_);
|
||||
}
|
||||
|
||||
bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != this->address_)
|
||||
return false;
|
||||
|
||||
const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID);
|
||||
for (const auto &manufacturer_data : device.get_manufacturer_datas()) {
|
||||
if (manufacturer_data.uuid != manufacturer_uuid)
|
||||
continue;
|
||||
|
||||
const auto &data = manufacturer_data.data;
|
||||
if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) {
|
||||
if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) {
|
||||
ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast<unsigned>(data.size()));
|
||||
this->warned_ |= WARNED_INVALID_LENGTH;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Aranet4 has no device type byte, so its flags and trailer start one byte earlier
|
||||
const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH;
|
||||
if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) {
|
||||
if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) {
|
||||
ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device");
|
||||
this->warned_ |= WARNED_INTEGRATION_DISABLED;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) {
|
||||
if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) {
|
||||
ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]);
|
||||
this->warned_ |= WARNED_UNSUPPORTED_TYPE;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const auto device_type =
|
||||
is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast<AranetDeviceType>(data[0]);
|
||||
|
||||
// Battery, measurement interval, measurement age and measurement counter
|
||||
const uint8_t *trailer = &data[is_aranet4 ? 15 : 17];
|
||||
const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]);
|
||||
const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]);
|
||||
const uint8_t measurement_counter = trailer[6];
|
||||
|
||||
publish_if_set(this->measurement_age_sensor_, measurement_age);
|
||||
publish_if_set(this->signal_strength_sensor_, device.get_rssi());
|
||||
|
||||
if (measurement_counter == this->last_measurement_counter_)
|
||||
return true;
|
||||
this->last_measurement_counter_ = measurement_counter;
|
||||
|
||||
// Not meaningful for Aranet Radiation, which stores its dose duration in these bytes
|
||||
const float temperature = encode_uint16(data[11], data[10]) / 20.0f;
|
||||
const float pressure = encode_uint16(data[13], data[12]) / 10.0f;
|
||||
switch (device_type) {
|
||||
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: {
|
||||
const uint16_t co2 = encode_uint16(data[9], data[8]);
|
||||
publish_if_set(this->co2_sensor_, co2);
|
||||
publish_if_set(this->temperature_sensor_, temperature);
|
||||
publish_if_set(this->pressure_sensor_, pressure);
|
||||
publish_if_set(this->humidity_sensor_, data[14]);
|
||||
ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature,
|
||||
data[14], pressure);
|
||||
break;
|
||||
}
|
||||
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: {
|
||||
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
|
||||
publish_if_set(this->temperature_sensor_, temperature);
|
||||
publish_if_set(this->humidity_sensor_, humidity);
|
||||
ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity);
|
||||
break;
|
||||
}
|
||||
case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: {
|
||||
const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]);
|
||||
const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]);
|
||||
const uint16_t radiation_rate = encode_uint16(data[15], data[14]);
|
||||
publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f);
|
||||
publish_if_set(this->radiation_total_sensor_, radiation_total);
|
||||
publish_if_set(this->radiation_duration_sensor_, radiation_duration);
|
||||
ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate,
|
||||
radiation_total);
|
||||
break;
|
||||
}
|
||||
case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: {
|
||||
const uint16_t radon = encode_uint16(data[9], data[8]);
|
||||
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
|
||||
publish_if_set(this->radon_sensor_, radon);
|
||||
// Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model.
|
||||
if (temperature != 0)
|
||||
publish_if_set(this->temperature_sensor_, temperature);
|
||||
publish_if_set(this->pressure_sensor_, pressure);
|
||||
if (humidity != 0)
|
||||
publish_if_set(this->humidity_sensor_, humidity);
|
||||
ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon,
|
||||
temperature, humidity, pressure);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
publish_if_set(this->battery_level_sensor_, trailer[0]);
|
||||
publish_if_set(this->measurement_interval_sensor_, measurement_interval);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace esphome::aranet
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::aranet {
|
||||
|
||||
class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
explicit Aranet(uint64_t address) : address_(address) {}
|
||||
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0;
|
||||
static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1;
|
||||
static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2;
|
||||
|
||||
// Small state first so it fills the padding before the first pointer
|
||||
uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement
|
||||
uint8_t warned_{0};
|
||||
|
||||
SUB_SENSOR(co2)
|
||||
SUB_SENSOR(temperature)
|
||||
SUB_SENSOR(humidity)
|
||||
SUB_SENSOR(pressure)
|
||||
SUB_SENSOR(battery_level)
|
||||
SUB_SENSOR(measurement_interval)
|
||||
SUB_SENSOR(measurement_age)
|
||||
SUB_SENSOR(signal_strength)
|
||||
SUB_SENSOR(radon)
|
||||
SUB_SENSOR(radiation_rate)
|
||||
SUB_SENSOR(radiation_total)
|
||||
SUB_SENSOR(radiation_duration)
|
||||
|
||||
protected:
|
||||
uint64_t address_;
|
||||
};
|
||||
|
||||
} // namespace esphome::aranet
|
||||
@@ -0,0 +1,150 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_LEVEL,
|
||||
CONF_CO2,
|
||||
CONF_HUMIDITY,
|
||||
CONF_ID,
|
||||
CONF_MAC_ADDRESS,
|
||||
CONF_PRESSURE,
|
||||
CONF_RADON,
|
||||
CONF_SIGNAL_STRENGTH,
|
||||
CONF_TEMPERATURE,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PRESSURE,
|
||||
DEVICE_CLASS_RADON,
|
||||
DEVICE_CLASS_SIGNAL_STRENGTH,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
ICON_RADIOACTIVE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_BECQUEREL_PER_CUBIC_METER,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_DECIBEL_MILLIWATT,
|
||||
UNIT_HECTOPASCAL,
|
||||
UNIT_MICROSILVERTS_PER_HOUR,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
UNIT_PERCENT,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
|
||||
CONF_MEASUREMENT_AGE = "measurement_age"
|
||||
CONF_MEASUREMENT_INTERVAL = "measurement_interval"
|
||||
CONF_RADIATION_RATE = "radiation_rate"
|
||||
CONF_RADIATION_TOTAL = "radiation_total"
|
||||
CONF_RADIATION_DURATION = "radiation_duration"
|
||||
UNIT_NANOSIEVERT = "nSv"
|
||||
|
||||
aranet_ns = cg.esphome_ns.namespace("aranet")
|
||||
Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component)
|
||||
|
||||
DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_SECOND,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Aranet),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_CO2): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_MILLION,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_PRESSURE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_HECTOPASCAL,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_PRESSURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_BATTERY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA,
|
||||
cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA,
|
||||
cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
cv.Optional(CONF_RADON): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_RADON,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR,
|
||||
accuracy_decimals=3,
|
||||
icon=ICON_RADIOACTIVE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_NANOSIEVERT,
|
||||
accuracy_decimals=0,
|
||||
icon=ICON_RADIOACTIVE,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_SECOND,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_device_base.BLE_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex)
|
||||
await cg.register_component(var, config)
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_CO2, var.set_co2_sensor)
|
||||
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
|
||||
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
|
||||
await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor)
|
||||
await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor)
|
||||
await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor)
|
||||
await sensors(CONF_RADON, var.set_radon_sensor)
|
||||
await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor)
|
||||
await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor)
|
||||
await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor)
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,6 +22,13 @@ enum class AdcGain : uint8_t {
|
||||
};
|
||||
|
||||
class BH1745Component : public PollingComponent, public i2c::I2CDevice {
|
||||
SUB_SENSOR(red_counts)
|
||||
SUB_SENSOR(green_counts)
|
||||
SUB_SENSOR(blue_counts)
|
||||
SUB_SENSOR(clear_counts)
|
||||
SUB_SENSOR(illuminance)
|
||||
SUB_SENSOR(color_temperature)
|
||||
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
@@ -31,25 +38,11 @@ class BH1745Component : public PollingComponent, public i2c::I2CDevice {
|
||||
void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; }
|
||||
void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; }
|
||||
|
||||
void set_red_counts_sensor(sensor::Sensor *red) { this->red_counts_sensor_ = red; }
|
||||
void set_green_counts_sensor(sensor::Sensor *green) { this->green_counts_sensor_ = green; }
|
||||
void set_blue_counts_sensor(sensor::Sensor *blue) { this->blue_counts_sensor_ = blue; }
|
||||
void set_clear_counts_sensor(sensor::Sensor *clear) { this->clear_counts_sensor_ = clear; }
|
||||
void set_illuminance_sensor(sensor::Sensor *illuminance) { this->illuminance_sensor_ = illuminance; }
|
||||
void set_color_temperature_sensor(sensor::Sensor *cct) { this->color_temperature_sensor_ = cct; }
|
||||
|
||||
/// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds.
|
||||
void enable_interrupt_output() { this->interrupt_output_ = true; }
|
||||
void set_interrupt_state(bool on);
|
||||
|
||||
protected:
|
||||
sensor::Sensor *red_counts_sensor_{nullptr};
|
||||
sensor::Sensor *green_counts_sensor_{nullptr};
|
||||
sensor::Sensor *blue_counts_sensor_{nullptr};
|
||||
sensor::Sensor *clear_counts_sensor_{nullptr};
|
||||
sensor::Sensor *illuminance_sensor_{nullptr};
|
||||
sensor::Sensor *color_temperature_sensor_{nullptr};
|
||||
|
||||
float glass_attenuation_factor_{1.0f};
|
||||
float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup()
|
||||
MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS};
|
||||
|
||||
@@ -74,6 +74,10 @@ from esphome.types import ConfigType
|
||||
from esphome.util import Registry
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
DEFAULT_INVALID_COOLDOWN_MS = (
|
||||
1000 # Keep in sync with invalid_cooldown_ in automation.h
|
||||
)
|
||||
DEVICE_CLASSES = [
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_BATTERY_CHARGING,
|
||||
@@ -131,10 +135,9 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa
|
||||
DoubleClickTrigger = binary_sensor_ns.class_(
|
||||
"DoubleClickTrigger", automation.Trigger.template()
|
||||
)
|
||||
MultiClickTriggerBase = binary_sensor_ns.class_(
|
||||
"MultiClickTriggerBase", automation.Trigger.template(), cg.Component
|
||||
MultiClickTrigger = binary_sensor_ns.class_(
|
||||
"MultiClickTrigger", automation.Trigger.template(), cg.Component
|
||||
)
|
||||
MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase)
|
||||
MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent")
|
||||
|
||||
|
||||
@@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
cv.Length(min=1, max=255),
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_INVALID_COOLDOWN, default="1s"
|
||||
CONF_INVALID_COOLDOWN,
|
||||
default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms",
|
||||
): cv.positive_time_period_milliseconds,
|
||||
}
|
||||
),
|
||||
@@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config):
|
||||
)
|
||||
for tim in conf[CONF_TIMING]
|
||||
]
|
||||
trigger = cg.new_Pvariable(
|
||||
conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings
|
||||
table = cg.shared_progmem_array(
|
||||
"multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings)
|
||||
)
|
||||
if CONF_INVALID_COOLDOWN in conf:
|
||||
cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN]))
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings))
|
||||
if (
|
||||
cooldown := conf[CONF_INVALID_COOLDOWN]
|
||||
).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS:
|
||||
cg.add(trigger.set_invalid_cooldown(cooldown))
|
||||
await cg.register_component(trigger, conf)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1;
|
||||
constexpr uint32_t MULTICLICK_IS_VALID_ID = 2;
|
||||
constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3;
|
||||
|
||||
void MultiClickTriggerBase::on_state_(bool state) {
|
||||
void MultiClickTrigger::on_state_(bool state) {
|
||||
// Handle duplicate events
|
||||
if (state == this->last_state_) {
|
||||
return;
|
||||
@@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this->at_index_.has_value()) {
|
||||
if (this->at_index_ == 0) {
|
||||
// Start matching
|
||||
MultiClickTriggerEvent evt = this->timing_[0];
|
||||
if (evt.state == state) {
|
||||
@@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) {
|
||||
return;
|
||||
}
|
||||
|
||||
// at_index_ has a value here (the !has_value() branch above returns).
|
||||
size_t at_index = *this->at_index_;
|
||||
uint8_t at_index = this->at_index_;
|
||||
if (at_index == this->timing_count_) {
|
||||
this->trigger_();
|
||||
return;
|
||||
@@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) {
|
||||
MultiClickTriggerEvent evt = this->timing_[at_index];
|
||||
|
||||
if (evt.max_length != 4294967294UL) {
|
||||
ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT
|
||||
ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length);
|
||||
this->schedule_is_valid_(evt.min_length);
|
||||
this->schedule_is_not_valid_(evt.max_length);
|
||||
} else if (at_index + 1 != this->timing_count_) {
|
||||
ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
|
||||
ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length);
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
this->schedule_is_valid_(evt.min_length);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
|
||||
ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length);
|
||||
this->is_valid_ = false;
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); });
|
||||
@@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) {
|
||||
|
||||
this->at_index_ = at_index + 1;
|
||||
}
|
||||
void MultiClickTriggerBase::schedule_cooldown_() {
|
||||
void MultiClickTrigger::schedule_cooldown_() {
|
||||
ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_);
|
||||
this->is_in_cooldown_ = true;
|
||||
this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() {
|
||||
ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again.");
|
||||
this->is_in_cooldown_ = false;
|
||||
});
|
||||
this->at_index_.reset();
|
||||
this->at_index_ = 0;
|
||||
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
}
|
||||
void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) {
|
||||
void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
|
||||
if (min_length == 0) {
|
||||
this->is_valid_ = true;
|
||||
return;
|
||||
@@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) {
|
||||
this->is_valid_ = true;
|
||||
});
|
||||
}
|
||||
void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) {
|
||||
void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) {
|
||||
this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() {
|
||||
ESP_LOGV(TAG, "Multi Click: You waited too long to %s.",
|
||||
this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS"));
|
||||
@@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) {
|
||||
this->schedule_cooldown_();
|
||||
});
|
||||
}
|
||||
void MultiClickTriggerBase::cancel() {
|
||||
void MultiClickTrigger::cancel() {
|
||||
ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled.");
|
||||
this->is_valid_ = false;
|
||||
this->schedule_cooldown_();
|
||||
}
|
||||
void MultiClickTriggerBase::trigger_() {
|
||||
void MultiClickTrigger::trigger_() {
|
||||
ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!");
|
||||
this->at_index_.reset();
|
||||
this->at_index_ = 0;
|
||||
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
|
||||
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cinttypes>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
@@ -13,10 +13,14 @@
|
||||
namespace esphome::binary_sensor {
|
||||
|
||||
struct MultiClickTriggerEvent {
|
||||
bool state;
|
||||
uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266
|
||||
uint32_t min_length;
|
||||
uint32_t max_length;
|
||||
};
|
||||
// Read straight from PROGMEM; a byte-sized field would fault on ESP8266.
|
||||
static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t> &&
|
||||
sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t),
|
||||
"MultiClickTriggerEvent fields must all be uint32_t");
|
||||
|
||||
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
|
||||
|
||||
@@ -70,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> {
|
||||
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
|
||||
};
|
||||
|
||||
/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header).
|
||||
class MultiClickTriggerBase : public Trigger<>, public Component {
|
||||
class MultiClickTrigger final : public Trigger<>, public Component {
|
||||
public:
|
||||
explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {}
|
||||
/// `timing` is a codegen PROGMEM table shared by triggers with the same timing.
|
||||
MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count)
|
||||
: parent_(parent), timing_(timing), timing_count_(timing_count) {}
|
||||
|
||||
void setup() override {
|
||||
this->last_state_ = this->parent_->get_state_default(false);
|
||||
@@ -85,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component {
|
||||
void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; }
|
||||
|
||||
void cancel();
|
||||
MultiClickTriggerBase(const MultiClickTriggerBase &) = delete;
|
||||
MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete;
|
||||
MultiClickTrigger(const MultiClickTrigger &) = delete;
|
||||
MultiClickTrigger &operator=(const MultiClickTrigger &) = delete;
|
||||
|
||||
protected:
|
||||
void on_state_(bool state);
|
||||
@@ -96,28 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component {
|
||||
void trigger_();
|
||||
|
||||
BinarySensor *parent_;
|
||||
const MultiClickTriggerEvent *timing_{nullptr};
|
||||
uint32_t invalid_cooldown_{1000};
|
||||
optional<size_t> at_index_{};
|
||||
uint8_t timing_count_{0};
|
||||
const MultiClickTriggerEvent *timing_;
|
||||
uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py
|
||||
uint8_t timing_count_;
|
||||
uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event
|
||||
bool last_state_{false};
|
||||
bool is_in_cooldown_{false};
|
||||
bool is_valid_{false};
|
||||
};
|
||||
|
||||
/// Template wrapper that provides inline std::array storage for timing events.
|
||||
/// N is set by code generation to match the exact number of timing events configured in YAML.
|
||||
template<size_t N> class MultiClickTrigger final : public MultiClickTriggerBase {
|
||||
public:
|
||||
MultiClickTrigger(BinarySensor *parent, std::initializer_list<MultiClickTriggerEvent> timing)
|
||||
: MultiClickTriggerBase(parent) {
|
||||
init_array_from(this->timing_storage_, timing);
|
||||
this->timing_ = this->timing_storage_.data();
|
||||
this->timing_count_ = N;
|
||||
}
|
||||
|
||||
protected:
|
||||
std::array<MultiClickTriggerEvent, N> timing_storage_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::binary_sensor
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace esphome::ble_client {
|
||||
|
||||
const char *const Automation::TAG = "ble_client.automation";
|
||||
const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266
|
||||
|
||||
} // namespace esphome::ble_client
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,8 +5,8 @@ from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import CORE, ID, Lambda
|
||||
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
CODEOWNERS = ["@Rapsssito"]
|
||||
@@ -14,6 +14,7 @@ DEPENDENCIES = ["i2c"]
|
||||
DOMAIN = "bq25186"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_BQ25186_ID = "bq25186_id"
|
||||
CONF_CONFIGURE_ON_BOOT = "configure_on_boot"
|
||||
CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage"
|
||||
CONF_FAST_CHARGE_CURRENT = "fast_charge_current"
|
||||
@@ -43,6 +44,7 @@ CONF_WAKE2_TIMER = "wake2_timer"
|
||||
CONF_ENABLE_PUSH_BUTTON = "enable_push_button"
|
||||
CONF_SYSTEM_REGULATION = "system_regulation"
|
||||
CONF_SYS_MODE = "sys_mode"
|
||||
CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable"
|
||||
CONF_DISABLE_VDPPM = "disable_vdppm"
|
||||
CONF_TS_HOT = "ts_hot"
|
||||
CONF_TS_COLD = "ts_cold"
|
||||
@@ -56,7 +58,26 @@ CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt"
|
||||
CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt"
|
||||
|
||||
bq25186_ns = cg.esphome_ns.namespace("bq25186")
|
||||
BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice)
|
||||
BQ25186Component = bq25186_ns.class_(
|
||||
"BQ25186Component", cg.PollingComponent, i2c.I2CDevice
|
||||
)
|
||||
|
||||
BQ25186_CHILD_SCHEMA = cv.Schema(
|
||||
{cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)}
|
||||
)
|
||||
_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT")
|
||||
_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT")
|
||||
|
||||
|
||||
def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression:
|
||||
_request_binary_sensor_slot(str(hub))
|
||||
return hub.register_binary_sensor(var)
|
||||
|
||||
|
||||
def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression:
|
||||
_request_text_sensor_slot(str(hub))
|
||||
return hub.register_text_sensor(var)
|
||||
|
||||
|
||||
REG_VBAT_CTRL = 0x03
|
||||
REG_ICHG_CTRL = 0x04
|
||||
@@ -69,7 +90,6 @@ REG_SYS_REG = 0x0A
|
||||
REG_TS_CONTROL = 0x0B
|
||||
REG_MASK_ID = 0x0C
|
||||
REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1
|
||||
WATCHDOG_DISABLED = 0x03
|
||||
|
||||
TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3}
|
||||
VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3}
|
||||
@@ -86,6 +106,22 @@ BATTERY_UVLO_OPTIONS = {
|
||||
VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1}
|
||||
RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1}
|
||||
SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3}
|
||||
# The I2C watchdog restores the power-on registers when the bus stays quiet for its period
|
||||
WATCHDOG_OPTIONS = {
|
||||
"160s_software_reset": 0,
|
||||
"160s_hardware_reset": 1,
|
||||
"40s_hardware_reset": 2,
|
||||
"disabled": 3,
|
||||
}
|
||||
WATCHDOG_PERIOD_S = {
|
||||
"160s_software_reset": 160,
|
||||
"160s_hardware_reset": 160,
|
||||
"40s_hardware_reset": 40,
|
||||
}
|
||||
# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects
|
||||
WATCHDOG_15S_PERIOD_S = 15
|
||||
# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied
|
||||
POWER_ON_WATCHDOG_PERIOD_S = 160
|
||||
LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3}
|
||||
AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3}
|
||||
INPUT_CURRENT_LIMIT_OPTIONS = {
|
||||
@@ -146,8 +182,8 @@ def _on_current_step(amps: float) -> float:
|
||||
|
||||
|
||||
# (config key, register, bit shift, validator or enum options, default).
|
||||
# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are
|
||||
# driven at runtime, not from configuration.
|
||||
# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO
|
||||
# switch drives them.
|
||||
FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
|
||||
(
|
||||
CONF_BATTERY_REGULATION_VOLTAGE,
|
||||
@@ -179,6 +215,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
|
||||
(CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"),
|
||||
(CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False),
|
||||
(CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"),
|
||||
(CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"),
|
||||
(CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"),
|
||||
(CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False),
|
||||
(CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"),
|
||||
@@ -195,6 +232,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
|
||||
(CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True),
|
||||
(CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"),
|
||||
(CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"),
|
||||
(CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False),
|
||||
(CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False),
|
||||
(CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"),
|
||||
(CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"),
|
||||
@@ -232,9 +270,6 @@ _ENCODERS: dict[str, Callable[[Any], int]] = {
|
||||
def pack_registers(config: ConfigType) -> list[int]:
|
||||
"""Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes."""
|
||||
regs = [0] * REGISTER_COUNT
|
||||
# Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running
|
||||
# watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling.
|
||||
regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED
|
||||
for key, register, shift, validator, _ in FIELDS:
|
||||
value = config[key]
|
||||
if isinstance(validator, dict):
|
||||
@@ -259,7 +294,28 @@ def _field_schema(with_defaults: bool) -> cv.Schema:
|
||||
|
||||
BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
|
||||
def _check_watchdog(config: ConfigType) -> ConfigType:
|
||||
"""Only the status poll talks to the chip, so it has to come round before any watchdog does."""
|
||||
periods = []
|
||||
if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None:
|
||||
periods.append(period)
|
||||
if config[CONF_WATCHDOG_15S_ENABLE]:
|
||||
periods.append(WATCHDOG_15S_PERIOD_S)
|
||||
if not config.get(CONF_CONFIGURE_ON_BOOT, True):
|
||||
periods.append(POWER_ON_WATCHDOG_PERIOD_S)
|
||||
if not periods:
|
||||
return config
|
||||
if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))):
|
||||
raise cv.Invalid(
|
||||
f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the "
|
||||
f"watchdog resets the charger between status polls",
|
||||
path=[CONF_UPDATE_INTERVAL],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BQ25186Component),
|
||||
@@ -267,7 +323,9 @@ CONFIG_SCHEMA = (
|
||||
}
|
||||
)
|
||||
.extend(BQ25186_CONFIG_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x6A))
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(i2c.i2c_device_schema(0x6A)),
|
||||
_check_watchdog,
|
||||
)
|
||||
|
||||
CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
@@ -305,7 +363,12 @@ async def bq25186_configure_to_code(
|
||||
# Options the action leaves out fall back to the component's configuration
|
||||
path = CORE.config.get_path_for_id(config[CONF_ID])[:-1]
|
||||
parent_config = CORE.config.get_config_for_path(path)
|
||||
regs = pack_registers({**parent_config, **config})
|
||||
merged = {**parent_config, **config}
|
||||
try:
|
||||
_check_watchdog(merged)
|
||||
except cv.Invalid as err:
|
||||
raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err
|
||||
regs = pack_registers(merged)
|
||||
|
||||
text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs))))
|
||||
lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from functools import partial
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_BATTERY_CHARGING,
|
||||
DEVICE_CLASS_OPENING,
|
||||
DEVICE_CLASS_PLUG,
|
||||
DEVICE_CLASS_RUNNING,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor
|
||||
|
||||
DEPENDENCIES = ["bq25186"]
|
||||
|
||||
BQ25186BinarySensor = bq25186_ns.class_(
|
||||
"BQ25186BinarySensor", binary_sensor.BinarySensor
|
||||
)
|
||||
|
||||
REG_STAT0 = 0
|
||||
REG_STAT1 = 1
|
||||
REG_FLAG0 = 2
|
||||
# Bit set for each field value that reads as on: a single bit is on when it is 1
|
||||
_BIT_SET = 0b10
|
||||
|
||||
# (key, status register, bit shift, field mask, on-values bitmap, device class)
|
||||
_FIELDS = (
|
||||
("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING),
|
||||
("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING),
|
||||
("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED),
|
||||
("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
|
||||
("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
|
||||
("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
|
||||
("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
|
||||
("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG),
|
||||
("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
|
||||
("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
|
||||
("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED),
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
BQ25186_CHILD_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(key): binary_sensor.binary_sensor_schema(
|
||||
BQ25186BinarySensor, device_class=device_class
|
||||
)
|
||||
for key, *_, device_class in _FIELDS
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_BQ25186_ID])
|
||||
sensors = binary_sensor.sub_binary_sensors(config)
|
||||
for key, reg, shift, mask, match, _ in _FIELDS:
|
||||
await sensors(
|
||||
key, partial(register_binary_sensor, hub), reg, shift, mask, match
|
||||
)
|
||||
@@ -1,4 +1,10 @@
|
||||
#include "bq25186.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "bq25186_binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
#include "bq25186_text_sensor.h"
|
||||
#endif
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -7,10 +13,10 @@ namespace esphome::bq25186 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "bq25186");
|
||||
|
||||
// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode,
|
||||
// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read
|
||||
// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset
|
||||
// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read
|
||||
static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = {
|
||||
0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0,
|
||||
};
|
||||
|
||||
bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) {
|
||||
@@ -31,16 +37,38 @@ bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value
|
||||
return this->write_register_(reg, reg_value);
|
||||
}
|
||||
|
||||
uint8_t BQ25186Component::expected_register_(size_t index) const {
|
||||
uint8_t value = this->regs_[index];
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
if (index == 0 && this->pg_gpo_switch_ != nullptr)
|
||||
value |= BQ25186_VBAT_CTRL_PG_MODE;
|
||||
#endif
|
||||
return value;
|
||||
}
|
||||
|
||||
bool BQ25186Component::config_matches_(const BQ25186StatusRegisters ®s) const {
|
||||
for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) {
|
||||
const size_t index = reg - BQ25186_REG_VBAT_CTRL;
|
||||
const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]);
|
||||
if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) {
|
||||
// Setup rejects an unknown chip; the action must not program it before that check or after it failed
|
||||
if (!this->is_ready() || this->is_failed()) {
|
||||
ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded");
|
||||
return false;
|
||||
}
|
||||
this->regs_ = regs;
|
||||
this->configured_ = true;
|
||||
for (size_t i = 0; i < regs.size(); i++) {
|
||||
const uint8_t reg = BQ25186_REG_VBAT_CTRL + i;
|
||||
const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]);
|
||||
const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]);
|
||||
const uint8_t value = this->expected_register_(i);
|
||||
const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value);
|
||||
if (!ok) {
|
||||
this->status_set_warning(LOG_STR("Configuration partly applied"));
|
||||
return false;
|
||||
@@ -68,12 +96,80 @@ void BQ25186Component::setup() {
|
||||
// A failed write only sets the warning status: the chip has identified itself, so a later
|
||||
// apply_configuration may retry
|
||||
if (this->configure_on_boot_)
|
||||
this->apply_config(this->setup_regs_);
|
||||
this->apply_config(this->regs_);
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
if (this->pg_gpo_switch_ != nullptr) {
|
||||
// The poll below puts the pin in output mode; without a restore mode the switch takes its level from it
|
||||
auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode();
|
||||
if (initial.has_value())
|
||||
this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin
|
||||
}
|
||||
#endif
|
||||
// The poller first runs after a random share of the interval; the entities get a state now
|
||||
this->update();
|
||||
}
|
||||
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
// The pin mode and level come from the switch, so every poll puts back whatever the chip lost
|
||||
void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) {
|
||||
if (this->pg_gpo_switch_ == nullptr)
|
||||
return;
|
||||
if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 &&
|
||||
!this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) {
|
||||
this->status_set_warning(LOG_STR("PG/GPO pin not in output mode"));
|
||||
pending = true;
|
||||
}
|
||||
const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0;
|
||||
if (!this->pg_gpo_switch_->has_state()) {
|
||||
this->pg_gpo_switch_->publish_state(level);
|
||||
} else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) {
|
||||
this->status_set_warning(LOG_STR("PG/GPO level write failed"));
|
||||
pending = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Every poll also serves the I2C watchdog, which wants to see traffic within its period
|
||||
void BQ25186Component::update() {
|
||||
BQ25186StatusRegisters status;
|
||||
if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) {
|
||||
this->status_set_warning(LOG_STR("Status read failed"));
|
||||
return;
|
||||
}
|
||||
// Whatever failed is tried again here, so the warning only stays while something is still wrong
|
||||
bool pending = false;
|
||||
// A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register
|
||||
// behind; either way the configuration is written again
|
||||
if (this->configured_ && !this->config_matches_(status)) {
|
||||
ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again");
|
||||
if (this->apply_config(this->regs_)) {
|
||||
status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured
|
||||
} else {
|
||||
pending = true;
|
||||
}
|
||||
}
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
this->check_pg_gpo_(status, pending);
|
||||
#endif
|
||||
if (!pending)
|
||||
this->status_clear_warning();
|
||||
#ifdef BQ25186_BINARY_SENSOR_COUNT
|
||||
for (auto *sensor : this->binary_sensors_)
|
||||
sensor->on_status(status);
|
||||
#endif
|
||||
#ifdef BQ25186_TEXT_SENSOR_COUNT
|
||||
for (auto *sensor : this->text_sensors_)
|
||||
sensor->on_status(status);
|
||||
#endif
|
||||
}
|
||||
|
||||
void BQ25186Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "BQ25186:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
static constexpr uint8_t BQ25186_REG_STAT0 = 0x00;
|
||||
static constexpr uint8_t BQ25186_REG_STAT1 = 0x01;
|
||||
static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02;
|
||||
static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03;
|
||||
static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04;
|
||||
static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05;
|
||||
@@ -18,27 +26,70 @@ static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A;
|
||||
static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B;
|
||||
static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C;
|
||||
static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1;
|
||||
// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration
|
||||
// registers, which show whether the chip was reset or a write failed
|
||||
static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1;
|
||||
|
||||
// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level
|
||||
static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80;
|
||||
static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10;
|
||||
|
||||
// Register values for VBAT_CTRL..MASK_ID, packed by code generation
|
||||
using BQ25186ConfigRegisters = std::array<uint8_t, BQ25186_CONFIG_REGISTER_COUNT>;
|
||||
using BQ25186StatusRegisters = std::array<uint8_t, BQ25186_STATUS_REGISTER_COUNT>;
|
||||
|
||||
class BQ25186Component : public Component, public i2c::I2CDevice {
|
||||
class BQ25186BinarySensor;
|
||||
class BQ25186TextSensor;
|
||||
|
||||
class BQ25186Component : public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_setup_registers(const BQ25186ConfigRegisters ®s) {
|
||||
this->setup_regs_ = regs;
|
||||
this->regs_ = regs;
|
||||
this->configure_on_boot_ = true;
|
||||
}
|
||||
bool apply_config(const BQ25186ConfigRegisters ®s);
|
||||
#ifdef BQ25186_BINARY_SENSOR_COUNT
|
||||
void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); }
|
||||
#endif
|
||||
#ifdef BQ25186_TEXT_SENSOR_COUNT
|
||||
void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); }
|
||||
#endif
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
/// The /PG/GPO pin is put in general purpose output mode when a switch drives it
|
||||
void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; }
|
||||
bool write_pg_gpo_level(bool level) {
|
||||
return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL,
|
||||
level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0);
|
||||
}
|
||||
#endif
|
||||
/// Write the masked bits of SHIP_RST
|
||||
bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); }
|
||||
|
||||
protected:
|
||||
bool write_register_(uint8_t reg, uint8_t value);
|
||||
uint8_t expected_register_(size_t index) const;
|
||||
bool config_matches_(const BQ25186StatusRegisters ®s) const;
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending);
|
||||
#endif
|
||||
bool update_register_(uint8_t reg, uint8_t mask, uint8_t value);
|
||||
|
||||
BQ25186ConfigRegisters setup_regs_{};
|
||||
#ifdef BQ25186_BINARY_SENSOR_COUNT
|
||||
StaticVector<BQ25186BinarySensor *, BQ25186_BINARY_SENSOR_COUNT> binary_sensors_;
|
||||
#endif
|
||||
#ifdef BQ25186_TEXT_SENSOR_COUNT
|
||||
StaticVector<BQ25186TextSensor *, BQ25186_TEXT_SENSOR_COUNT> text_sensors_;
|
||||
#endif
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
switch_::Switch *pg_gpo_switch_{nullptr};
|
||||
#endif
|
||||
BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset
|
||||
bool configure_on_boot_{false};
|
||||
bool configured_{false}; // regs_ went to the chip at least once
|
||||
};
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
|
||||
#include "bq25186.h"
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
/// One field of a status register; `match` has a bit set for each field value that reads as on
|
||||
class BQ25186BinarySensor final : public binary_sensor::BinarySensor {
|
||||
public:
|
||||
BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match)
|
||||
: reg_(reg), shift_(shift), mask_(mask), match_(match) {}
|
||||
|
||||
void on_status(const BQ25186StatusRegisters &status) {
|
||||
const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_;
|
||||
this->publish_state((this->match_ >> value) & 1);
|
||||
}
|
||||
|
||||
protected:
|
||||
const uint8_t reg_;
|
||||
const uint8_t shift_;
|
||||
const uint8_t mask_;
|
||||
const uint8_t match_;
|
||||
};
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
#endif // USE_BINARY_SENSOR
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BUTTON
|
||||
|
||||
#include "bq25186.h"
|
||||
#include "esphome/components/button/button.h"
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
/// Writes one SHIP_RST field when pressed
|
||||
class BQ25186Button final : public button::Button, public Parented<BQ25186Component> {
|
||||
public:
|
||||
BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {}
|
||||
|
||||
protected:
|
||||
void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); }
|
||||
|
||||
const uint8_t mask_;
|
||||
const uint8_t value_;
|
||||
};
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
#endif // USE_BUTTON
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BQ25186_PG_GPO
|
||||
|
||||
#include "bq25186.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented<BQ25186Component> {
|
||||
protected:
|
||||
void write_state(bool state) override {
|
||||
if (this->parent_->write_pg_gpo_level(state))
|
||||
this->publish_state(state);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
#endif // USE_BQ25186_PG_GPO
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "bq25186_text_sensor.h"
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
// STAT0[6:5] charge status, then STAT1[4:3] TS status
|
||||
PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled",
|
||||
"Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)");
|
||||
|
||||
void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) {
|
||||
const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03;
|
||||
if (value == this->last_)
|
||||
return;
|
||||
this->last_ = value;
|
||||
const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX);
|
||||
char text[32];
|
||||
ESPHOME_strncpy_P(text, reinterpret_cast<ESPHOME_PGM_P>(name), sizeof(text));
|
||||
text[sizeof(text) - 1] = '\0';
|
||||
this->publish_state(text);
|
||||
}
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
#endif // USE_TEXT_SENSOR
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
#include "bq25186.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
namespace esphome::bq25186 {
|
||||
|
||||
/// A two-bit status field; `first` is the string table index of the field's value 0
|
||||
class BQ25186TextSensor final : public text_sensor::TextSensor {
|
||||
public:
|
||||
BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {}
|
||||
void on_status(const BQ25186StatusRegisters &status);
|
||||
|
||||
protected:
|
||||
const uint8_t reg_;
|
||||
const uint8_t shift_;
|
||||
const uint8_t first_;
|
||||
uint8_t last_{0xFF}; // the field value last published
|
||||
};
|
||||
|
||||
} // namespace esphome::bq25186
|
||||
|
||||
#endif // USE_TEXT_SENSOR
|
||||
@@ -0,0 +1,52 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_RESTART,
|
||||
ENTITY_CATEGORY_CONFIG,
|
||||
ICON_POWER,
|
||||
ICON_RESTART,
|
||||
ICON_RESTART_ALERT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
|
||||
|
||||
DEPENDENCIES = ["bq25186"]
|
||||
|
||||
BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button)
|
||||
|
||||
# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes
|
||||
SOFTWARE_RESET_MASK = 0x80
|
||||
MODE_MASK = 0x60
|
||||
|
||||
# (key, SHIP_RST mask, value, device class, icon)
|
||||
_BUTTONS = (
|
||||
("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART),
|
||||
("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER),
|
||||
("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER),
|
||||
("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT),
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
BQ25186_CHILD_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(key): button.button_schema(
|
||||
BQ25186Button,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon=icon,
|
||||
device_class=device_class,
|
||||
)
|
||||
for key, _, _, device_class, icon in _BUTTONS
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
for key, mask, value, _, _ in _BUTTONS:
|
||||
if (conf := config.get(key)) is not None:
|
||||
var = await button.new_button(conf, mask, value)
|
||||
await cg.register_parented(var, config[CONF_BQ25186_ID])
|
||||
@@ -0,0 +1,27 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
|
||||
|
||||
DEPENDENCIES = ["bq25186"]
|
||||
|
||||
CONF_PG_GPO = "pg_gpo"
|
||||
|
||||
BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch)
|
||||
|
||||
CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_PG_GPO): switch.switch_schema(
|
||||
BQ25186PgGpoSwitch, default_restore_mode="DISABLED"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_BQ25186_PG_GPO")
|
||||
hub = await cg.get_variable(config[CONF_BQ25186_ID])
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_PG_GPO, hub.set_pg_gpo_switch)
|
||||
@@ -0,0 +1,38 @@
|
||||
from functools import partial
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor
|
||||
|
||||
DEPENDENCIES = ["bq25186"]
|
||||
|
||||
BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor)
|
||||
|
||||
REG_STAT0 = 0
|
||||
REG_STAT1 = 1
|
||||
|
||||
# (key, status register, bit shift, index of the field's value 0 in the C++ string table)
|
||||
_FIELDS = (
|
||||
("charge_status", REG_STAT0, 5, 0),
|
||||
("ts_status", REG_STAT1, 3, 4),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
BQ25186_CHILD_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor)
|
||||
for key, *_ in _FIELDS
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_BQ25186_ID])
|
||||
sensors = text_sensor.sub_text_sensors(config)
|
||||
for key, reg, shift, first in _FIELDS:
|
||||
await sensors(key, partial(register_text_sensor, hub), reg, shift, first)
|
||||
@@ -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;
|
||||
|
||||
@@ -59,6 +59,7 @@ CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROLE = "role"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_SCAN_PARAMETERS = "scan_parameters"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
CONF_SHA256 = "sha256"
|
||||
CONF_SLOT = "slot"
|
||||
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from esphome import git
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.noise import generate_encryption_key
|
||||
from esphome.components.packages import validate_source_shorthand
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI
|
||||
@@ -175,8 +174,7 @@ def import_config(
|
||||
"esphome": esphome_core,
|
||||
}
|
||||
if encryption:
|
||||
noise_psk = secrets.token_bytes(32)
|
||||
key = base64.b64encode(noise_psk).decode()
|
||||
key = generate_encryption_key()
|
||||
config["api"] = {"encryption": {"key": key}}
|
||||
|
||||
output = dump(config)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_client, esp32_ble
|
||||
from esphome.components import ble_client, esp32_ble, time
|
||||
from esphome.components.esp32_ble import (
|
||||
CONF_AUTH_REQ_MODE,
|
||||
CONF_IO_CAPABILITY,
|
||||
CONF_MAX_KEY_SIZE,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.const import CONF_ID, CONF_TIME_ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
@@ -27,6 +27,7 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Ecocomfort2Hub),
|
||||
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
@@ -52,6 +53,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await ble_client.register_ble_node(var, config)
|
||||
if (time_id := config.get(CONF_TIME_ID)) is not None:
|
||||
cg.add(var.set_time(await cg.get_variable(time_id)))
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
from esphome.components.const import CONF_CONNECTED
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA
|
||||
|
||||
CODEOWNERS = ["@gledian"]
|
||||
DEPENDENCIES = ["ecocomfort2"]
|
||||
|
||||
CONF_BOOST = "boost"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ECOCOMFORT2_CLIENT_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_CONNECTIVITY,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
cv.Optional(CONF_BOOST): binary_sensor.binary_sensor_schema(),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_CONNECTED, CONF_BOOST),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID])
|
||||
sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await sensors(CONF_CONNECTED, hub.set_connected_binary_sensor)
|
||||
await sensors(CONF_BOOST, hub.set_boost_binary_sensor)
|
||||
@@ -3,22 +3,39 @@
|
||||
#include "ecocomfort2_hub.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace esphome::ecocomfort2 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ecocomfort2");
|
||||
|
||||
static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d");
|
||||
static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1");
|
||||
void Ecocomfort2Hub::setup() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_binary_sensor_ != nullptr)
|
||||
this->connected_binary_sensor_->publish_initial_state(false);
|
||||
#endif
|
||||
#ifdef USE_TIME
|
||||
// The clock is written after each time-source sync
|
||||
if (this->time_ != nullptr)
|
||||
this->time_->add_on_time_sync_callback([this]() { this->sync_clock_(); });
|
||||
#endif
|
||||
}
|
||||
|
||||
void Ecocomfort2Hub::update() {
|
||||
if (!this->is_ready()) {
|
||||
ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str());
|
||||
return;
|
||||
}
|
||||
auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
|
||||
this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE);
|
||||
// The GATT client runs the requests one after the other
|
||||
this->read_char_(this->char_handle_state_);
|
||||
this->read_char_(this->char_handle_oper_);
|
||||
}
|
||||
|
||||
void Ecocomfort2Hub::read_char_(uint16_t handle) {
|
||||
auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), handle,
|
||||
ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status);
|
||||
ESP_LOGW(TAG, "[%s] Read request for handle 0x%x failed, status=%d", this->parent_->address_str(), handle, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +45,22 @@ void Ecocomfort2Hub::dump_config() {
|
||||
" Address: %s",
|
||||
this->parent_->address_str());
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
#ifdef USE_TIME
|
||||
ESP_LOGCONFIG(TAG, " Clock sync: %s", YESNO(this->time_ != nullptr));
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
|
||||
LOG_SENSOR(" ", "VOC", this->voc_sensor_);
|
||||
LOG_SENSOR(" ", "Direction", this->direction_sensor_);
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Connected", this->connected_binary_sensor_);
|
||||
LOG_BINARY_SENSOR(" ", "Boost", this->boost_binary_sensor_);
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
LOG_TEXT_SENSOR(" ", "Firmware", this->firmware_text_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
@@ -39,17 +72,34 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if
|
||||
this->cancel_timeout(READBACK_TIMEOUT_ID);
|
||||
this->status_set_warning();
|
||||
this->char_handle_oper_ = 0;
|
||||
this->char_handle_state_ = 0;
|
||||
this->char_handle_info_ = 0;
|
||||
this->char_handle_clock_ = 0;
|
||||
this->set_connected_(false);
|
||||
this->publish_unknown_();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
// Keep the handle, not the characteristic: ble_client frees its services once all nodes are established.
|
||||
auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID);
|
||||
if (chr == nullptr) {
|
||||
ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str());
|
||||
const auto service_uuid = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d");
|
||||
const auto oper_uuid = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1");
|
||||
const auto state_uuid = espbt::ESPBTUUID::from_raw("438d3433-7e5a-459a-a8e4-66343fad2bb0");
|
||||
const auto info_uuid = espbt::ESPBTUUID::from_raw("f5f56229-dd4f-480f-a829-9189269d8b37");
|
||||
const auto clock_uuid = espbt::ESPBTUUID::from_raw("82788997-49e4-4533-b949-7ed433678044");
|
||||
// Keep the handles, not the characteristics: ble_client frees its services once all nodes are established.
|
||||
auto *oper = this->parent_->get_characteristic(service_uuid, oper_uuid);
|
||||
auto *state = this->parent_->get_characteristic(service_uuid, state_uuid);
|
||||
if (oper == nullptr || state == nullptr) {
|
||||
ESP_LOGW(TAG, "[%s] C_OPER or C_STATE characteristic not found, disconnecting", this->parent_->address_str());
|
||||
this->parent_->disconnect();
|
||||
break;
|
||||
}
|
||||
this->char_handle_oper_ = chr->handle;
|
||||
this->char_handle_oper_ = oper->handle;
|
||||
this->char_handle_state_ = state->handle;
|
||||
// The version and clock characteristics only feed optional features, so a unit without them still works
|
||||
auto *info = this->parent_->get_characteristic(service_uuid, info_uuid);
|
||||
this->char_handle_info_ = info != nullptr ? info->handle : 0;
|
||||
auto *clock = this->parent_->get_characteristic(service_uuid, clock_uuid);
|
||||
this->char_handle_clock_ = clock != nullptr ? clock->handle : 0;
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->status_set_warning();
|
||||
|
||||
@@ -71,31 +121,22 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_READ_CHAR_EVT: {
|
||||
if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) {
|
||||
if (param->read.conn_id != this->parent_->get_conn_id()) {
|
||||
break;
|
||||
}
|
||||
if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) {
|
||||
// ble_client reconnects and pairs again
|
||||
ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str());
|
||||
this->parent_->disconnect();
|
||||
const uint16_t handle = param->read.handle;
|
||||
if (handle == this->char_handle_oper_) {
|
||||
if (this->read_ok_(param))
|
||||
this->on_oper_(param->read.value, param->read.value_len);
|
||||
} else if (handle == this->char_handle_state_) {
|
||||
if (this->read_ok_(param))
|
||||
this->on_state_(param->read.value, param->read.value_len);
|
||||
} else if (handle == this->char_handle_info_) {
|
||||
if (this->read_ok_(param))
|
||||
this->on_info_(param->read.value, param->read.value_len);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (param->read.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status);
|
||||
break;
|
||||
}
|
||||
if (param->read.value_len < 2) {
|
||||
ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len);
|
||||
break;
|
||||
}
|
||||
this->mode_ = param->read.value[0];
|
||||
this->speed_ = param->read.value[1];
|
||||
ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_);
|
||||
#ifdef ECOCOMFORT2_CHILD_COUNT
|
||||
for (auto *child : this->children_) {
|
||||
child->on_status();
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -103,6 +144,21 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if
|
||||
}
|
||||
}
|
||||
|
||||
bool Ecocomfort2Hub::read_ok_(const esp_ble_gattc_cb_param_t *param) {
|
||||
if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) {
|
||||
// ble_client reconnects and pairs again
|
||||
ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str());
|
||||
this->parent_->disconnect();
|
||||
return false;
|
||||
}
|
||||
if (param->read.status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "[%s] Read of handle 0x%x failed, status=%d", this->parent_->address_str(), param->read.handle,
|
||||
param->read.status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) ||
|
||||
this->node_state != espbt::ClientState::ESTABLISHED) {
|
||||
@@ -122,9 +178,122 @@ void Ecocomfort2Hub::on_paired_() {
|
||||
this->cancel_timeout(AUTH_TIMEOUT_ID);
|
||||
this->status_clear_warning();
|
||||
ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str());
|
||||
this->set_connected_(true);
|
||||
#ifdef USE_TIME
|
||||
if (this->time_ != nullptr)
|
||||
this->sync_clock_();
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
if (this->firmware_text_sensor_ != nullptr && this->char_handle_info_ != 0)
|
||||
this->read_char_(this->char_handle_info_);
|
||||
#endif
|
||||
this->update();
|
||||
}
|
||||
|
||||
void Ecocomfort2Hub::set_connected_(bool connected) {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->connected_binary_sensor_ != nullptr)
|
||||
this->connected_binary_sensor_->publish_state(connected);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Readings from a unit that is out of reach would otherwise look current for hours
|
||||
void Ecocomfort2Hub::publish_unknown_() {
|
||||
#ifdef USE_SENSOR
|
||||
for (auto *sensor : {this->temperature_sensor_, this->humidity_sensor_, this->voc_sensor_, this->direction_sensor_}) {
|
||||
if (sensor != nullptr)
|
||||
sensor->publish_state(NAN);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// C_OPER: mode byte, speed/flags byte
|
||||
void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) {
|
||||
if (len < 2) {
|
||||
ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), len);
|
||||
return;
|
||||
}
|
||||
this->mode_ = data[0];
|
||||
this->speed_ = data[1];
|
||||
ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->boost_binary_sensor_ != nullptr)
|
||||
this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0);
|
||||
#endif
|
||||
#ifdef ECOCOMFORT2_CHILD_COUNT
|
||||
for (auto *child : this->children_) {
|
||||
child->on_status();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction
|
||||
void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) {
|
||||
if (len < 9) {
|
||||
ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len);
|
||||
return;
|
||||
}
|
||||
const float temperature = static_cast<int16_t>(encode_uint16(data[3], data[4])) / 100.0f;
|
||||
const float humidity = encode_uint16(data[5], data[6]) / 100.0f;
|
||||
const uint16_t voc = encode_uint16(data[7], data[8]);
|
||||
ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature,
|
||||
humidity, voc, data[2]);
|
||||
#ifdef USE_SENSOR
|
||||
if (this->temperature_sensor_ != nullptr)
|
||||
this->temperature_sensor_->publish_state(temperature);
|
||||
if (this->humidity_sensor_ != nullptr)
|
||||
this->humidity_sensor_->publish_state(humidity);
|
||||
if (this->voc_sensor_ != nullptr)
|
||||
this->voc_sensor_->publish_state(voc);
|
||||
if (this->direction_sensor_ != nullptr)
|
||||
this->direction_sensor_->publish_state(data[2]);
|
||||
#endif
|
||||
}
|
||||
|
||||
// C_INFO: firmware version packed as 4-bit major, 6-bit minor and 6-bit patch
|
||||
void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) {
|
||||
if (len < 2) {
|
||||
ESP_LOGW(TAG, "[%s] C_INFO too short: %u bytes", this->parent_->address_str(), len);
|
||||
return;
|
||||
}
|
||||
const uint16_t field = encode_uint16(data[0] & 0x0F, data[1]);
|
||||
char version[12];
|
||||
snprintf(version, sizeof(version), "%u.%u.%u", data[0] >> 4, field >> 6, field & 0x3F);
|
||||
ESP_LOGD(TAG, "[%s] Firmware %s", this->parent_->address_str(), version);
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
if (this->firmware_text_sensor_ != nullptr)
|
||||
this->firmware_text_sensor_->publish_state(version);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_TIME
|
||||
// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes
|
||||
void Ecocomfort2Hub::sync_clock_() {
|
||||
if (!this->is_ready() || this->char_handle_clock_ == 0)
|
||||
return;
|
||||
const ESPTime now = this->time_->now();
|
||||
if (!now.is_valid())
|
||||
return;
|
||||
// ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6
|
||||
const uint8_t weekday = (now.day_of_week + 5) % 7;
|
||||
uint8_t data[8]{now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
weekday,
|
||||
now.day_of_month,
|
||||
now.month,
|
||||
static_cast<uint8_t>(now.year >> 8),
|
||||
static_cast<uint8_t>(now.year)};
|
||||
ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_,
|
||||
sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "[%s] C_CLOCK write failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) {
|
||||
if (!this->is_ready()) {
|
||||
ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str());
|
||||
@@ -139,7 +308,7 @@ void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) {
|
||||
ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status);
|
||||
return;
|
||||
}
|
||||
this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); });
|
||||
this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->read_char_(this->char_handle_oper_); });
|
||||
}
|
||||
|
||||
#ifdef ECOCOMFORT2_CHILD_COUNT
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "ecocomfort2_child.h"
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ecocomfort2 {
|
||||
|
||||
namespace espbt = esphome::esp32_ble_tracker;
|
||||
@@ -33,6 +46,7 @@ static constexpr uint8_t FLAG_NIGHT = 0x80;
|
||||
/** Hub component for Ecocomfort 2.0 VMC via BLE. */
|
||||
class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::BLUETOOTH; }
|
||||
@@ -44,6 +58,23 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling
|
||||
#ifdef ECOCOMFORT2_CHILD_COUNT
|
||||
void register_child(Ecocomfort2Client *obj);
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
void set_temperature_sensor(sensor::Sensor *s) { this->temperature_sensor_ = s; }
|
||||
void set_humidity_sensor(sensor::Sensor *s) { this->humidity_sensor_ = s; }
|
||||
void set_voc_sensor(sensor::Sensor *s) { this->voc_sensor_ = s; }
|
||||
void set_direction_sensor(sensor::Sensor *s) { this->direction_sensor_ = s; }
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void set_connected_binary_sensor(binary_sensor::BinarySensor *s) { this->connected_binary_sensor_ = s; }
|
||||
void set_boost_binary_sensor(binary_sensor::BinarySensor *s) { this->boost_binary_sensor_ = s; }
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
void set_firmware_text_sensor(text_sensor::TextSensor *s) { this->firmware_text_sensor_ = s; }
|
||||
#endif
|
||||
#ifdef USE_TIME
|
||||
/// Keeps the unit's clock in step with this time source while connected.
|
||||
void set_time(time::RealTimeClock *time) { this->time_ = time; }
|
||||
#endif
|
||||
|
||||
/// True once the link is established and paired.
|
||||
bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); }
|
||||
@@ -62,11 +93,40 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling
|
||||
static constexpr uint32_t READBACK_DELAY_MS = 300;
|
||||
|
||||
void on_paired_();
|
||||
void set_connected_(bool connected);
|
||||
void publish_unknown_();
|
||||
void read_char_(uint16_t handle);
|
||||
bool read_ok_(const esp_ble_gattc_cb_param_t *param);
|
||||
void on_oper_(const uint8_t *data, uint16_t len);
|
||||
void on_state_(const uint8_t *data, uint16_t len);
|
||||
void on_info_(const uint8_t *data, uint16_t len);
|
||||
#ifdef USE_TIME
|
||||
void sync_clock_();
|
||||
#endif
|
||||
|
||||
#ifdef ECOCOMFORT2_CHILD_COUNT
|
||||
StaticVector<Ecocomfort2Client *, ECOCOMFORT2_CHILD_COUNT> children_;
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
sensor::Sensor *temperature_sensor_{nullptr};
|
||||
sensor::Sensor *humidity_sensor_{nullptr};
|
||||
sensor::Sensor *voc_sensor_{nullptr};
|
||||
sensor::Sensor *direction_sensor_{nullptr};
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *connected_binary_sensor_{nullptr};
|
||||
binary_sensor::BinarySensor *boost_binary_sensor_{nullptr};
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
text_sensor::TextSensor *firmware_text_sensor_{nullptr};
|
||||
#endif
|
||||
#ifdef USE_TIME
|
||||
time::RealTimeClock *time_{nullptr};
|
||||
#endif
|
||||
uint16_t char_handle_oper_{0};
|
||||
uint16_t char_handle_state_{0};
|
||||
uint16_t char_handle_info_{0};
|
||||
uint16_t char_handle_clock_{0};
|
||||
uint8_t mode_{OPER_OFF};
|
||||
uint8_t speed_{SPEED_OFF};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DIRECTION,
|
||||
CONF_HUMIDITY,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_VOC,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PARTS_PER_BILLION,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA
|
||||
|
||||
CODEOWNERS = ["@gledian"]
|
||||
DEPENDENCIES = ["ecocomfort2"]
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ECOCOMFORT2_CLIENT_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_VOC): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_BILLION,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_DIRECTION): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY, CONF_VOC, CONF_DIRECTION),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
|
||||
await sensors(CONF_HUMIDITY, hub.set_humidity_sensor)
|
||||
await sensors(CONF_VOC, hub.set_voc_sensor)
|
||||
await sensors(CONF_DIRECTION, hub.set_direction_sensor)
|
||||
@@ -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
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA
|
||||
|
||||
CODEOWNERS = ["@gledian"]
|
||||
DEPENDENCIES = ["ecocomfort2"]
|
||||
|
||||
CONFIG_SCHEMA = ECOCOMFORT2_CLIENT_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_VERSION): text_sensor.text_sensor_schema(
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID])
|
||||
sensors = text_sensor.sub_text_sensors(config)
|
||||
await sensors(CONF_VERSION, hub.set_firmware_text_sensor)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from esphome.components.noise import (
|
||||
ENCRYPTION_SCHEMA,
|
||||
new_psk_progmem,
|
||||
static_encryption_key,
|
||||
validate_encryption_key,
|
||||
)
|
||||
from esphome.components.ota import (
|
||||
BASE_OTA_SCHEMA,
|
||||
@@ -32,7 +33,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD
|
||||
from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -99,16 +100,18 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
|
||||
# Encryption blocks conflict only when both pin a key; a bare
|
||||
# `encryption:` (a package/device split) is compatible with a
|
||||
# keyed one, and merge_config yields the keyed result
|
||||
merged_key = (
|
||||
merged_ota_esphome_configs_by_port[conf_port]
|
||||
.get(CONF_ENCRYPTION, {})
|
||||
.get(CONF_KEY)
|
||||
merged_enc = merged_ota_esphome_configs_by_port[conf_port].get(
|
||||
CONF_ENCRYPTION, {}
|
||||
)
|
||||
other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
||||
if merged_key and other_key and merged_key != other_key:
|
||||
raise cv.Invalid(
|
||||
f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent"
|
||||
)
|
||||
other_enc = ota_conf.get(CONF_ENCRYPTION, {})
|
||||
for option in (CONF_KEY, CONF_OLD_KEY):
|
||||
merged_key = merged_enc.get(option)
|
||||
other_key = other_enc.get(option)
|
||||
if merged_key and other_key and merged_key != other_key:
|
||||
raise cv.Invalid(
|
||||
f"Found multiple configurations but {CONF_ENCRYPTION} "
|
||||
f"{option} is inconsistent"
|
||||
)
|
||||
|
||||
ports_with_merged_configs.append(conf_port)
|
||||
merged_ota_esphome_configs_by_port[conf_port] = merge_config(
|
||||
@@ -206,7 +209,8 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -
|
||||
"""Resolve the one encryption key per device into the ota block.
|
||||
|
||||
An explicit ota key must match the api key, a bare block inherits it,
|
||||
a runtime provisioned api key cannot be inherited.
|
||||
a runtime provisioned api key cannot be inherited, and an old_key equal
|
||||
to the resolved key is a leftover from a finished rotation.
|
||||
"""
|
||||
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
|
||||
if ota_key := encryption_conf.get(CONF_KEY):
|
||||
@@ -229,12 +233,19 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -
|
||||
)
|
||||
else:
|
||||
encryption_conf[CONF_KEY] = api_key
|
||||
if encryption_conf.get(CONF_OLD_KEY) == encryption_conf[CONF_KEY]:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_OLD_KEY}' is the same as '{CONF_KEY}'; remove '{CONF_OLD_KEY}' "
|
||||
"once the device runs the new key"
|
||||
)
|
||||
|
||||
|
||||
# Uploader side options live only on the ota block; the api block keeps the
|
||||
# shared schema
|
||||
# shared schema. The firmware is built with `key` alone, `old_key` never
|
||||
# reaches the device
|
||||
_ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_OLD_KEY): cv.sensitive(validate_encryption_key),
|
||||
cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -36,11 +36,17 @@ async def to_code(config: ConfigType) -> None:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
cg.add(var.set_pin(pin))
|
||||
|
||||
if CONF_INTERLOCK in config:
|
||||
if (interlock := config.get(CONF_INTERLOCK)) is not None:
|
||||
cg.add_define("USE_GPIO_SWITCH_INTERLOCK")
|
||||
interlock = []
|
||||
for it in config[CONF_INTERLOCK]:
|
||||
lock = await cg.get_variable(it)
|
||||
interlock.append(lock)
|
||||
cg.add(var.set_interlock(interlock))
|
||||
locks = [await cg.get_variable(it) for it in interlock]
|
||||
# The group's table lists this switch too, so every member shares one table.
|
||||
group = sorted({str(s): s for s in (var, *locks)}.values(), key=str)
|
||||
if len(group) > 1:
|
||||
table = cg.shared_progmem_array(
|
||||
"gpio_interlock",
|
||||
switch.Switch.operator("ptr"),
|
||||
cg.ArrayInitializer(*group),
|
||||
constexpr=False,
|
||||
)
|
||||
cg.add(var.set_interlock(table, len(group)))
|
||||
cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME]))
|
||||
|
||||
@@ -67,8 +67,4 @@ void GPIOSwitch::write_state(bool state) {
|
||||
this->publish_state(state);
|
||||
}
|
||||
|
||||
#ifdef USE_GPIO_SWITCH_INTERLOCK
|
||||
void GPIOSwitch::set_interlock(const std::initializer_list<Switch *> &interlock) { this->interlock_ = interlock; }
|
||||
#endif
|
||||
|
||||
} // namespace esphome::gpio
|
||||
|
||||
@@ -21,7 +21,10 @@ class GPIOSwitch final : public switch_::Switch, public Component {
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
#ifdef USE_GPIO_SWITCH_INTERLOCK
|
||||
void set_interlock(const std::initializer_list<Switch *> &interlock);
|
||||
/// Codegen only: the table must outlive this switch; it may list this switch, which is skipped.
|
||||
void set_interlock(Switch *const *interlock, size_t count) {
|
||||
this->interlock_ = ConstVector<Switch *>(interlock, count);
|
||||
}
|
||||
void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; }
|
||||
#endif
|
||||
|
||||
@@ -30,7 +33,7 @@ class GPIOSwitch final : public switch_::Switch, public Component {
|
||||
|
||||
GPIOPin *pin_{nullptr};
|
||||
#ifdef USE_GPIO_SWITCH_INTERLOCK
|
||||
FixedVector<Switch *> interlock_;
|
||||
ConstVector<Switch *> interlock_;
|
||||
uint32_t interlock_wait_time_{0};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -24,9 +24,13 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned
|
||||
return; \
|
||||
}
|
||||
|
||||
// Reset sequence (Goodix GT911 datasheet): INT held low while RST is released selects address 0x5D, then
|
||||
// T3 + T4 pass before INT is handed back to the chip as its interrupt output
|
||||
static constexpr uint32_t RESET_LOW_MS = 10;
|
||||
static constexpr uint32_t RESET_SETTLE_MS = 5 + 50 + 1;
|
||||
|
||||
void GT911Touchscreen::setup() {
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
// temporarily set the interrupt pin to output to control address selection
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(false);
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
@@ -34,20 +38,19 @@ void GT911Touchscreen::setup() {
|
||||
this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT);
|
||||
this->interrupt_pin_->digital_write(false);
|
||||
}
|
||||
delay(2);
|
||||
this->reset_pin_->digital_write(true);
|
||||
// wait at least T3+T4 ms as per the datasheet
|
||||
this->set_timeout(5 + 50 + 1, [this] { this->setup_internal_(); });
|
||||
this->set_timeout(RESET_LOW_MS, [this] {
|
||||
this->reset_pin_->digital_write(true);
|
||||
this->set_timeout(RESET_SETTLE_MS, [this] { this->setup_internal_(); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
this->setup_internal_();
|
||||
}
|
||||
|
||||
void GT911Touchscreen::setup_internal_() {
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
if (this->interrupt_pin_->is_internal())
|
||||
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
|
||||
}
|
||||
// Expander pins must leave output mode too, or the chip cannot drive its interrupt line
|
||||
if (this->interrupt_pin_ != nullptr)
|
||||
this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT);
|
||||
|
||||
uint8_t data[4];
|
||||
i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES));
|
||||
@@ -72,7 +75,7 @@ void GT911Touchscreen::setup_internal_() {
|
||||
ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s",
|
||||
active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW"));
|
||||
} else {
|
||||
// IO expander pin: leave as output for configuration only.
|
||||
// IO expander pin: no hardware interrupt, the touch state is polled.
|
||||
ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s",
|
||||
active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW"));
|
||||
}
|
||||
|
||||
@@ -14,17 +14,8 @@ class GT911ButtonListener {
|
||||
|
||||
class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice {
|
||||
public:
|
||||
/// @brief Initialize the GT911 touchscreen.
|
||||
///
|
||||
/// If @ref reset_pin_ is set, the touchscreen will be hardware reset,
|
||||
/// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout()
|
||||
/// to allow the device to stabilize after reset.
|
||||
///
|
||||
/// If @ref interrupt_pin_ is set, it will be temporarily configured during reset
|
||||
/// to control I2C address selection.
|
||||
///
|
||||
/// After the timeout, or immediately if no reset is performed, @ref setup_internal_()
|
||||
/// is called to complete the initialization.
|
||||
/// With a reset pin the chip is reset with the interrupt pin held low, which selects address 0x5D, and the
|
||||
/// rest of the setup runs from timeouts once the chip is ready. Without one setup_internal_() runs at once.
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
bool can_proceed() override { return this->setup_done_; }
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
from esphome.components.const import CONF_SERIAL_NUMBER
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
|
||||
from esphome.types import ConfigType
|
||||
@@ -9,7 +10,6 @@ from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
CONF_DOOR_STATE = "door_state"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
|
||||
HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user