Merge branch 'dev' into api-action-strings-progmem

This commit is contained in:
J. Nick Koston
2026-10-12 16:02:23 -10:00
committed by GitHub
711 changed files with 32392 additions and 3233 deletions
+18 -1
View File
@@ -202,6 +202,21 @@ 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`.
* **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
@@ -589,7 +604,9 @@ 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.
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
+37 -2
View File
@@ -39,9 +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
@@ -65,6 +68,7 @@ esphome/components/bedjet/climate/* @jhansche
esphome/components/bedjet/fan/* @jhansche
esphome/components/bedjet/sensor/* @javawizard @jhansche
esphome/components/beken_spi_led_strip/* @Mat931
esphome/components/bh1745/* @latonita
esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
@@ -100,6 +104,8 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bq25186/* @Rapsssito
esphome/components/bq27220/* @rggammon
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
@@ -156,8 +162,10 @@ esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds1603l/* @JakeLC15
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
@@ -183,6 +191,7 @@ esphome/components/esp32_camera_web_server/* @ayufan
esphome/components/esp32_can/* @Sympatron
esphome/components/esp32_hosted/* @swoboda1337
esphome/components/esp32_hosted/update/* @swoboda1337
esphome/components/esp32_power_management/* @rwrozelle
esphome/components/esp32_rmt/* @jesserockz
esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
@@ -197,6 +206,7 @@ esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
@@ -264,6 +274,7 @@ esphome/components/http_request/ota/* @oarcher
esphome/components/http_request/update/* @jesserockz
esphome/components/htu31d/* @betterengineering
esphome/components/hub75/* @stuartparmenter
esphome/components/husb238/* @latonita
esphome/components/hydreon_rgxx/* @functionpointer
esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core
@@ -271,6 +282,7 @@ esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
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/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
@@ -300,9 +312,11 @@ esphome/components/kuntze/* @ssieb
esphome/components/lc709203f/* @ilikecake
esphome/components/lcd_menu/* @numo68
esphome/components/ld2410/* @regevbr @sebcaps
esphome/components/ld2410s/* @NovakIrs
esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu
esphome/components/ld2460/* @jesusvallejo
esphome/components/ld24xx/* @kbx81
esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter
@@ -311,6 +325,10 @@ esphome/components/libretiny_pwm/* @kuba2k2
esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lis2dw12/* @latonita
esphome/components/lis2dw12_i2c/* @latonita
esphome/components/lis2dw12_spi/* @latonita
esphome/components/lis3dh/* @zebble
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
@@ -318,6 +336,7 @@ esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
esphome/components/lp5562/* @DennisGaida
esphome/components/lps22/* @nagisa
esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny
@@ -328,7 +347,9 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
esphome/components/max6956/* @looping40
esphome/components/max7219digit/* @rspaargaren
@@ -347,7 +368,6 @@ esphome/components/mcp4461/* @p1ngb4ck
esphome/components/mcp4728/* @berfenger
esphome/components/mcp47a1/* @jesserockz
esphome/components/mcp9600/* @mreditor97
esphome/components/mcp9808/* @k7hpn
esphome/components/md5/* @esphome/core
esphome/components/mdns/* @esphome/core
esphome/components/media_player/* @jesserockz
@@ -361,6 +381,7 @@ esphome/components/midea_ir/* @dudanov
esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
esphome/components/mistral_ir/* @lerhum
esphome/components/mitsubishi/* @RubyBailey
esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
@@ -399,6 +420,8 @@ esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/ns2009/* @tchilov
esphome/components/ns2009/touchscreen/* @tchilov
esphome/components/number/* @esphome/core
esphome/components/one_wire/* @ssieb
esphome/components/online_image/* @clydebarrow @guillempages
@@ -413,6 +436,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman
esphome/components/pcm5122/* @remcom
esphome/components/pd_pioneer_ir/* @martinsah
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
@@ -431,6 +455,7 @@ esphome/components/pn7160/* @jesserockz @kbx81
esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/pn71xx/* @jesserockz @kbx81
esphome/components/power_management/* @rwrozelle
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
@@ -451,6 +476,8 @@ esphome/components/rc522/* @glmnet
esphome/components/rc522_i2c/* @glmnet
esphome/components/rc522_spi/* @glmnet
esphome/components/rd03d/* @jasstrong
esphome/components/remko_ar715/* @naKruul
esphome/components/renogy_inverter_ble/* @emilioaray-dev
esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
@@ -470,6 +497,7 @@ esphome/components/runtime_stats/* @bdraco
esphome/components/rx8025t/* @remcom
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
esphome/components/samsung/* @jorofi
esphome/components/scd4x/* @martgras @sjtrny
esphome/components/script/* @esphome/core
esphome/components/sdl/* @bdm310 @clydebarrow
@@ -498,11 +526,14 @@ esphome/components/sfa30/* @ghsensdev
esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sh1122_base/* @pl4nkton
esphome/components/sh1122_spi/* @pl4nkton
esphome/components/sha256/* @esphome/core
esphome/components/shelly_dimmer/* @edge90 @rnauber
esphome/components/sht3xd/* @mrtoy-me
esphome/components/sht4x/* @sjtrny
esphome/components/shutdown/* @esphome/core @jsuanet
esphome/components/siemens_ira211/* @fonix232
esphome/components/sigma_delta_output/* @Cat-Ion
esphome/components/sim800l/* @glmnet
esphome/components/sm10bit_base/* @Cossid
@@ -565,6 +596,7 @@ esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
@@ -582,12 +614,15 @@ esphome/components/thermopro_ble/* @sittner
esphome/components/thermostat/* @kbx81
esphome/components/time/* @esphome/core
esphome/components/tinyusb/* @kbx81
esphome/components/tinyusb_keyboard/* @frederikbolding
esphome/components/tlc5947/* @rnauber
esphome/components/tlc5971/* @IJIJI
esphome/components/tm1621/* @Philippe12
esphome/components/tm1637/* @glmnet
esphome/components/tm1638/* @skykingjwc
esphome/components/tm1651/* @mrtoy-me
esphome/components/tmc2209/* @remcom
esphome/components/tmc22xx/* @remcom
esphome/components/tmp102/* @timsavage
esphome/components/tmp1075/* @sybrenstuvel
esphome/components/tmp117/* @Azimath
@@ -639,7 +674,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @glmnet
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
+147 -13
View File
@@ -48,8 +48,11 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
PLATFORM_HOST,
SECRETS_FILES,
ErrorFormat,
Toolchain,
@@ -70,6 +73,7 @@ from esphome.util import (
get_serial_ports,
is_picotool_usb_permission_error,
list_yaml_files,
read_secret_line,
run_external_command,
run_external_process,
safe_print,
@@ -1232,14 +1236,28 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
port_type = get_port_type(host)
ota_key = getattr(args, "ota_key", None)
if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL):
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
host,
)
platform_upload = platform_hooks.get_platform_hook(
CORE.target_platform, "upload_program"
)
if platform_upload is not None and platform_upload(config, args, host):
if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL):
# A platform's own network path (nrf52 mcumgr) has no key handshake
_LOGGER.warning(
"The presented OTA key only applies to the %s OTA platform; "
"ignored for %s",
CONF_ESPHOME,
host,
)
return 0, host
port_type = get_port_type(host)
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
is_partition_table = getattr(args, "partition_table", False)
@@ -1282,6 +1300,8 @@ def upload_program(
network_devices = _resolve_network_devices(devices, config, args)
if chosen_platform == CONF_WEB_SERVER:
if ota_key:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
if is_partition_table or is_bootloader:
raise EsphomeError(
f"{option_string} is only supported with the esphome OTA platform; "
@@ -1295,6 +1315,17 @@ def upload_program(
return _upload_via_native_api(config, network_devices, args)
def _ota_upload_platforms(config: ConfigType) -> dict[str, None]:
"""The network OTA platforms the config provides, one entry each, in
config order; the web_server final-validate hook merges duplicates."""
return {
ota_platform: None
for ota_item in config.get(CONF_OTA, [])
if (ota_platform := ota_item.get(CONF_PLATFORM))
in (CONF_ESPHOME, CONF_WEB_SERVER)
}
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
@@ -1307,16 +1338,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
``web_server`` only when that is the only available platform.
"""
# Use a dict (insertion-ordered) instead of a list so error messages and
# membership checks see one entry per platform even if the user has
# multiple ``ota:`` items of the same platform; the web_server OTA
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
available = _ota_upload_platforms(config)
if not available:
raise EsphomeError(
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
@@ -1354,9 +1376,15 @@ 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 (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
if ota_key := getattr(args, "ota_key", None):
# The user presents a key the device still runs instead of the
# configured one (a key change in progress); an explicit key never
# downgrades to plaintext
noise_psk = ota_key
elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
allow_plaintext_upload = bool(
encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD)
@@ -1369,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)
@@ -1418,9 +1448,22 @@ 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 config.get(CONF_OTA, []):
if item.get(CONF_PLATFORM) == CONF_ESPHOME and (
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]:
@@ -1738,7 +1781,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
OTA_KEY_PLATFORM_ERROR = (
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
"platform; to flash by serial instead, name the port with --device"
)
def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None:
"""Take the key to present to the device for this upload, from the
prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is
not buried in build output."""
prompt = getattr(args, "prompt_ota_key", False)
env_key = getattr(args, "env_ota_key", None)
if not prompt and env_key is None:
return
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST:
_LOGGER.warning("A host program runs locally; the presented OTA key is ignored")
return
# The HTTP path has no key handshake; decide before prompting or
# compiling. A serial target is a serial flash: the key is not even
# asked for, and --ota-platform means nothing to it. Every entry must
# be serial: a missing port is skipped and the next entry may be a host
devices = getattr(args, "device", None) or []
serial_target = bool(devices) and all(
device == "SERIAL"
or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL)
for device in devices
)
chosen = (
_choose_ota_platform(config, getattr(args, "ota_platform", None))
if not serial_target and _ota_upload_platforms(config)
else None
)
if chosen == CONF_WEB_SERVER:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
from esphome.components.noise import validate_encryption_key
from esphome.config_validation import Invalid
if serial_target:
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
", ".join(devices),
)
return
key = read_secret_line("OTA encryption key: ") if prompt else env_key
try:
validate_encryption_key(key)
except Invalid as err:
raise EsphomeError(f"Invalid OTA encryption key: {err}") from err
args.ota_key = key
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
_read_ota_key(args, config)
# Get devices, resolving special identifiers like OTA
devices = choose_upload_log_host(
default=args.device,
@@ -1786,6 +1882,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# Elsewhere the flag is ignored, so serial stays fine there.
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
return 1
_read_ota_key(args, config)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
@@ -1811,6 +1908,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
@@ -2216,6 +2314,15 @@ def _error_format(value: str) -> ErrorFormat:
) from None
ARGUMENT_HELP_PROMPT_OTA_KEY = (
"Ask for the OTA encryption key to present to the device instead of using "
"the one in the configuration, for a device that still runs a previous key. "
"The key is read without echo, or as one line from stdin when that is not "
"a terminal; it is never taken from the command line. A calling program "
f"can hand the key over in the {ENV_OTA_KEY} environment variable instead."
)
def parse_args(argv):
options_parser = argparse.ArgumentParser(add_help=False)
options_parser.add_argument(
@@ -2384,6 +2491,11 @@ def parse_args(argv):
help="Upload as partition table (OTA).",
action="store_true",
)
parser_upload.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_upload.add_argument(
"--bootloader",
help="Upload as bootloader (OTA).",
@@ -2464,6 +2576,11 @@ def parse_args(argv):
"(HTTP Basic auth) when that is the only configured platform."
),
)
parser_run.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_clean = subparsers.add_parser(
"clean-mqtt",
@@ -2689,6 +2806,23 @@ def run_esphome(argv):
args.log_level = "CRITICAL"
setup_log(log_level=args.log_level)
# Taken out of the environment before any command runs, so no build
# tool inherits a key a calling program handed over
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
if args.command in ("upload", "run"):
# One key is one device's; several configs run in child processes
# that would never see it
if (args.env_ota_key is not None or args.prompt_ota_key) and len(
args.configuration
) > 1:
raise EsphomeError(
f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; "
f"{len(args.configuration)} were given"
)
elif args.env_ota_key is not None:
_LOGGER.warning(
"%s is set but the %s command does not use it", ENV_OTA_KEY, args.command
)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
+39 -8
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable, Sequence
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import string
@@ -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.
@@ -465,6 +482,22 @@ def register_apply_action(
) -> None:
"""Register an action that only forwards config values to its parent, with no C++ class.
See ``apply_action_builder``.
"""
register_action(name, ApplyAction, schema, synchronous=True)(
apply_action_builder(name, schema, *fields, call=call, id_key=id_key)
)
def apply_action_builder(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
call: str | None = None,
id_key: str = CONF_ID,
) -> Callable[..., Awaitable[MockObj]]:
"""The builder ``register_apply_action`` registers, for a caller that wraps it.
Generates one static function with the parent (read from ``id_key``) and constants baked
in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it.
A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names.
@@ -475,10 +508,7 @@ def register_apply_action(
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)
]
@@ -515,7 +545,7 @@ def register_apply_action(
action_id, cg.void, template_arg, lambda_args, statements
)
register_action(name, ApplyAction, schema, synchronous=True)(builder)
return builder
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
@@ -547,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)
+20 -12
View File
@@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
)
# Matches !secret references in YAML text. An optional surrounding
# quote pair around the key is allowed and ignored: YAML treats
# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
# intentionally a simple regex scan rather than a YAML parse — it may
# match inside comments or multi-line strings, which is the conservative
# direction (include more secrets rather than fewer).
_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
"""Scan YAML files for ``!secret <key>`` references."""
keys: set[str] = set()
for fpath in yaml_files:
try:
text = fpath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError) as err:
_LOGGER.warning(
"Could not scan %s for !secret references (%s); the bundled "
"secret set may be incomplete",
fpath,
err,
)
continue
for match in _SECRET_RE.finditer(text):
keys.add(match.group(1))
keys |= yaml_util.find_secret_references(text)
return keys
@@ -422,6 +418,18 @@ class ConfigBundleCreator:
must ship every candidate so the remote build can pick any one.
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
if discovered.load_errors:
_LOGGER.warning(
"Bundle may be incomplete; could not load all configuration files: %s",
"; ".join(discovered.load_errors),
)
if discovered.unresolved:
_LOGGER.warning(
"Bundle may be incomplete; %d !include path(s) use "
"substitutions and cannot be captured: %s",
len(discovered.unresolved),
", ".join(discovered.unresolved),
)
self._secrets_paths.update(discovered.secrets)
# A !secret inside a file this re-parse does not reach (for example
# a git-fetched package the builder re-fetches) still resolves
+4 -4
View File
@@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
#endif // USE_RP2
protected:
uint8_t sample_count_{1};
bool output_raw_{false};
uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py
bool output_raw_{false}; // Must match CONF_RAW default in sensor.py
InternalGPIOPin *pin_;
SamplingMode sampling_mode_{SamplingMode::AVG};
SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py
#ifdef USE_ESP32
float sample_autorange_();
@@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
bool autorange_{false};
adc_oneshot_unit_handle_t adc_handle_{nullptr};
adc_cali_handle_t calibration_handle_{nullptr};
adc_atten_t attenuation_{ADC_ATTEN_DB_0};
adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py
adc_channel_t channel_{};
adc_unit_t adc_unit_{};
struct SetupFlags {
+24 -8
View File
@@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"]
CONF_SAMPLES = "samples"
CONF_SAMPLING_MODE = "sampling_mode"
# Keep in sync with the initializers in adc_sensor.h
DEFAULT_ATTENUATION = "0db"
DEFAULT_SAMPLING_MODE = "avg"
DEFAULT_SAMPLES = 1
_attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
@@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType:
):
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
if (
config.get(CONF_ATTENUATION, None) == "auto"
and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1
):
raise cv.Invalid(
"Automatic attenuation cannot be used when multisampling is set"
)
@@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_PIN): validate_adc_pin,
cv.Optional(CONF_RAW, default=False): cv.boolean,
cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All(
cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All(
cv.only_on_esp32, _attenuation
),
cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec),
cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255),
cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode,
cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range(
min=1, max=255
),
cv.Optional(
CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE
): _sampling_mode,
}
)
.extend(cv.polling_component_schema("60s")),
@@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_output_raw(config[CONF_RAW]))
cg.add(var.set_sample_count(config[CONF_SAMPLES]))
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
# Skip the setters when the config matches the C++ initializers.
if config[CONF_RAW]:
cg.add(var.set_output_raw(True))
if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES:
cg.add(var.set_sample_count(samples))
if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE:
cg.add(var.set_sampling_mode(sampling_mode))
if CORE.is_esp32:
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
@@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None:
if attenuation := config.get(CONF_ATTENUATION):
if attenuation == "auto":
cg.add(var.set_autorange(cg.global_ns.true))
else:
elif attenuation != DEFAULT_ATTENUATION:
cg.add(var.set_attenuation(attenuation))
variant = get_esp32_variant()
+3
View File
@@ -0,0 +1,3 @@
"""Support for ScioSense APC1 air quality sensor."""
DOMAIN = "apc1"
+352
View File
@@ -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
+99
View File
@@ -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
+222
View File
@@ -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)
)
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@iyesin"]
DOMAIN = "apds9930"
+140
View File
@@ -0,0 +1,140 @@
#include "apds9930.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::apds9930 {
ESPHOME_LOG_TAG(TAG, "apds9930");
// Register addresses
static constexpr uint8_t APDS9930_ENABLE = 0x00;
static constexpr uint8_t APDS9930_ATIME = 0x01;
static constexpr uint8_t APDS9930_PTIME = 0x02;
static constexpr uint8_t APDS9930_PPULSE = 0x0E;
static constexpr uint8_t APDS9930_CONTROL = 0x0F;
static constexpr uint8_t APDS9930_ID = 0x12;
static constexpr uint8_t APDS9930_STATUS = 0x13;
// Command byte: repeated byte and auto-increment protocols
static constexpr uint8_t APDS9930_CMD = 0x80;
static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0;
static constexpr uint8_t APDS9930_CHIP_ID = 0x39;
// ENABLE register bits
static constexpr uint8_t APDS9930_PON = 0x01;
static constexpr uint8_t APDS9930_AEN = 0x02;
static constexpr uint8_t APDS9930_PEN = 0x04;
// STATUS register bits
static constexpr uint8_t APDS9930_AVALID = 0x01;
static constexpr uint8_t APDS9930_PVALID = 0x02;
// CONTROL register fields
static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6;
static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4;
static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2;
static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0;
static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode
static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms
static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms
static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses
// Lux calculation coefficients
static constexpr float APDS9930_DF = 52.0f;
static constexpr float APDS9930_GA = 0.49f;
static constexpr float APDS9930_ALS_B = 1.862f;
static constexpr float APDS9930_ALS_C = 0.746f;
static constexpr float APDS9930_ALS_D = 1.291f;
static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE);
static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120};
static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8};
static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step
// Lux per count at 1x gain and the fixed integration time
static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS;
// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels
static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE);
bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); }
void APDS9930Component::setup() {
uint8_t id;
if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) {
this->mark_failed(LOG_STR("Communication failed"));
return;
}
if (id != APDS9930_CHIP_ID) {
ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID);
this->mark_failed(LOG_STR("Wrong chip ID"));
return;
}
const uint8_t control =
(this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT |
(this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT;
uint8_t enable = APDS9930_PON;
if (this->illuminance_sensor_ != nullptr)
enable |= APDS9930_AEN;
if (this->proximity_sensor_ != nullptr)
enable |= APDS9930_PEN;
if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) ||
!this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) ||
!this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) ||
!this->write_reg_(APDS9930_ENABLE, enable)) {
this->mark_failed(LOG_STR("Configuration failed"));
}
}
void APDS9930Component::dump_config() {
ESP_LOGCONFIG(TAG,
"APDS9930:\n"
" LED Drive: %.1f mA\n"
" Proximity Gain: %ux\n"
" Ambient Light Gain: %ux",
LED_CURRENT_MAX_MA / (1 << this->led_drive_),
progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]),
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Proximity", this->proximity_sensor_);
}
void APDS9930Component::update() {
// STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H
uint8_t raw[7];
if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) {
this->status_set_warning(LOG_STR("Reading data failed"));
return;
}
this->status_clear_warning();
const uint8_t status = raw[0];
if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) {
const uint16_t ch0 = encode_uint16(raw[2], raw[1]);
const uint16_t ch1 = encode_uint16(raw[4], raw[3]);
if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) {
// A saturated channel would give a lux value that is too low, not too high
ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain");
this->illuminance_sensor_->publish_state(NAN);
} else {
const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f});
this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X /
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
}
}
if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) {
this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5]));
}
}
} // namespace esphome::apds9930
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::apds9930 {
class APDS9930Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(illuminance)
SUB_SENSOR(proximity)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_led_drive(uint8_t level) { this->led_drive_ = level; }
void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; }
void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; }
protected:
bool write_reg_(uint8_t reg, uint8_t value);
uint8_t led_drive_{0};
uint8_t proximity_gain_{0};
uint8_t ambient_gain_{0};
};
} // namespace esphome::apds9930
+70
View File
@@ -0,0 +1,70 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import UNIT_COUNTS
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_ILLUMINANCE,
DEVICE_CLASS_ILLUMINANCE,
STATE_CLASS_MEASUREMENT,
UNIT_LUX,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain"
CONF_LED_DRIVE = "led_drive"
CONF_PROXIMITY = "proximity"
CONF_PROXIMITY_GAIN = "proximity_gain"
DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3}
PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3}
AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3}
apds9930_ns = cg.esphome_ns.namespace("apds9930")
APDS9930Component = apds9930_ns.class_(
"APDS9930Component", cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(APDS9930Component),
cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum(
DRIVE_LEVELS, lower=True
),
cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum(
PROXIMITY_LEVELS, lower=True
),
cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum(
AMBIENT_LEVELS, lower=True
),
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PROXIMITY): sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x39))
)
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_led_drive(config[CONF_LED_DRIVE]))
cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN]))
cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor)
await sensors(CONF_PROXIMITY, var.set_proximity_sensor)
+2
View File
@@ -237,6 +237,8 @@ void APIServer::refill_spare_ephemeral_() {
return;
}
}
// Generating the key pair cannot be split across loop passes
UnavoidableBlockingScope scope;
noise::prepare_spare_ephemeral();
}
#endif
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@patrickod"]
DOMAIN = "aranet"
+157
View File
@@ -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
+42
View File
@@ -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
+150
View File
@@ -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 -1
View File
@@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
+75
View File
@@ -0,0 +1,75 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_GAIN,
CONF_GLASS_ATTENUATION_FACTOR,
CONF_ID,
CONF_INTEGRATION_TIME,
)
from esphome.types import ConfigType
CODEOWNERS = ["@latonita"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bh1745"
MULTI_CONF = True
CONF_BH1745_ID = "bh1745_id"
bh1745_ns = cg.esphome_ns.namespace("bh1745")
BH1745Component = bh1745_ns.class_(
"BH1745Component", cg.PollingComponent, i2c.I2CDevice
)
AdcGain = bh1745_ns.enum("AdcGain", is_class=True)
ADC_GAINS = {
"1X": AdcGain.ADC_GAIN_1X,
"2X": AdcGain.ADC_GAIN_2X,
"16X": AdcGain.ADC_GAIN_16X,
}
MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True)
MEASUREMENT_TIMES = {
160: MeasurementTime.MEASUREMENT_TIME_160MS,
320: MeasurementTime.MEASUREMENT_TIME_320MS,
640: MeasurementTime.MEASUREMENT_TIME_640MS,
1280: MeasurementTime.MEASUREMENT_TIME_1280MS,
2560: MeasurementTime.MEASUREMENT_TIME_2560MS,
5120: MeasurementTime.MEASUREMENT_TIME_5120MS,
}
def validate_measurement_time(value: Any) -> Any:
value = cv.positive_time_period_milliseconds(value).total_milliseconds
return cv.enum(MEASUREMENT_TIMES, int=True)(value)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BH1745Component),
cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True),
cv.Optional(
CONF_INTEGRATION_TIME, default="160ms"
): validate_measurement_time,
cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range(
min=1.0
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x38))
)
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_adc_gain(config[CONF_GAIN]))
cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME]))
cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR]))
+182
View File
@@ -0,0 +1,182 @@
#include "bh1745.h"
#include <cinttypes>
#include <cmath>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bh1745 {
ESPHOME_LOG_TAG(TAG, "bh1745");
static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40;
static constexpr uint8_t REG_MODE_CONTROL1 = 0x41;
static constexpr uint8_t REG_MODE_CONTROL2 = 0x42;
static constexpr uint8_t REG_MODE_CONTROL3 = 0x44;
static constexpr uint8_t REG_RED_DATA_LSB = 0x50;
static constexpr uint8_t REG_INTERRUPT = 0x60;
static constexpr uint8_t REG_TH_LSB = 0x62;
static constexpr uint8_t REG_MANUFACTURER_ID = 0x92;
static constexpr uint8_t MANUFACTURER_ID = 0xE0;
static constexpr uint8_t PART_ID = 0b001011;
static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F;
static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80;
static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10;
static constexpr uint8_t MODE_CONTROL2_VALID = 0x80;
static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02
static constexpr uint8_t INTERRUPT_ENABLE = 0x01;
static constexpr uint8_t BH1745_CHANNELS = 4;
static constexpr uint32_t RESET_DELAY_MS = 100;
static constexpr uint32_t BASE_MEAS_TIME_MS = 160;
static uint32_t get_measurement_time_ms(MeasurementTime time) {
return BASE_MEAS_TIME_MS << static_cast<uint8_t>(time);
}
static uint8_t get_adc_gain(AdcGain gain) {
return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast<uint8_t>(gain);
}
static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) {
if (green == 0)
return 0.0f;
float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green;
return lx * scale;
}
static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) {
if (green == 0)
return 0.0f;
float all = static_cast<float>(static_cast<uint32_t>(red) + green + blue);
float r_ratio = red / all;
float b_ratio = blue / all;
float ct;
if (low_clear_ratio) {
float b_eff = std::fmin(b_ratio * 3.13f, 1.0f);
ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio));
} else {
float b_eff = std::fmin(b_ratio * 10.67f, 1.0f);
ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio));
}
return clamp_at_most(ct, 10000.0f);
}
void BH1745Component::setup() {
uint8_t manufacturer_id;
if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (manufacturer_id != MANUFACTURER_ID) {
this->mark_failed(LOG_STR("Wrong manufacturer ID"));
return;
}
uint8_t system_control;
if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) ||
(system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) {
this->mark_failed(LOG_STR("Wrong part ID"));
return;
}
if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Light lost behind glass is scaled back up, as the other light sensors do
this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ /
(get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_));
this->set_timeout(RESET_DELAY_MS, [this]() {
if (this->interrupt_output_) {
// High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always
// true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs.
static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF};
if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
// RGBC_EN makes the chip measure continuously; update() reads the latest result.
if (!this->write_byte(REG_MODE_CONTROL1, static_cast<uint8_t>(this->measurement_time_)) ||
!this->write_byte(REG_MODE_CONTROL2, static_cast<uint8_t>(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) ||
!this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->busy_ = false;
});
}
void BH1745Component::dump_config() {
ESP_LOGCONFIG(TAG,
"BH1745:\n"
" Gain: %ux\n"
" Integration time: %" PRIu32 " ms\n"
" Glass attenuation factor: %.2f",
get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_),
this->glass_attenuation_factor_);
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_);
LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_);
LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_);
LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_);
}
void BH1745Component::update() {
if (this->busy_)
return;
// Reading MODE_CONTROL2 clears VALID, so read it only once per poll.
uint8_t mode_control2;
if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((mode_control2 & MODE_CONTROL2_VALID) == 0)
return;
uint8_t buffer[BH1745_CHANNELS * 2];
if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->status_clear_warning();
// Each channel is sent low byte first.
const uint16_t red = encode_uint16(buffer[1], buffer[0]);
const uint16_t green = encode_uint16(buffer[3], buffer[2]);
const uint16_t blue = encode_uint16(buffer[5], buffer[4]);
const uint16_t clear = encode_uint16(buffer[7], buffer[6]);
ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear);
if (this->red_counts_sensor_ != nullptr)
this->red_counts_sensor_->publish_state(red);
if (this->green_counts_sensor_ != nullptr)
this->green_counts_sensor_->publish_state(green);
if (this->blue_counts_sensor_ != nullptr)
this->blue_counts_sensor_->publish_state(blue);
if (this->clear_counts_sensor_ != nullptr)
this->clear_counts_sensor_->publish_state(clear);
// The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25).
const bool low_clear_ratio = static_cast<uint32_t>(clear) * 25 < static_cast<uint32_t>(green) * 4;
if (this->illuminance_sensor_ != nullptr)
this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_));
if (this->color_temperature_sensor_ != nullptr)
this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio));
}
void BH1745Component::set_interrupt_state(bool on) {
if (this->is_failed())
return;
if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) {
this->status_set_warning();
}
}
} // namespace esphome::bh1745
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::bh1745 {
enum class MeasurementTime : uint8_t {
MEASUREMENT_TIME_160MS = 0b000,
MEASUREMENT_TIME_320MS = 0b001,
MEASUREMENT_TIME_640MS = 0b010,
MEASUREMENT_TIME_1280MS = 0b011,
MEASUREMENT_TIME_2560MS = 0b100,
MEASUREMENT_TIME_5120MS = 0b101,
};
enum class AdcGain : uint8_t {
ADC_GAIN_1X = 0,
ADC_GAIN_2X = 1,
ADC_GAIN_16X = 2,
};
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;
void update() override;
void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; }
void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; }
void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; }
/// 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:
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};
AdcGain adc_gain_{AdcGain::ADC_GAIN_1X};
bool busy_{true}; // cleared once the chip is configured after the reset
bool interrupt_output_{false};
};
} // namespace esphome::bh1745
@@ -0,0 +1,28 @@
import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns
DEPENDENCIES = ["bh1745"]
BH1745InterruptPinOutput = bh1745_ns.class_(
"BH1745InterruptPinOutput", output.BinaryOutput
)
CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput),
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
hub = await cg.get_variable(config[CONF_BH1745_ID])
await cg.register_parented(var, hub)
cg.add(hub.enable_interrupt_output())
@@ -0,0 +1,16 @@
#pragma once
#include "esphome/components/output/binary_output.h"
#include "esphome/core/helpers.h"
#include "../bh1745.h"
namespace esphome::bh1745 {
/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one.
class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented<BH1745Component> {
protected:
void write_state(bool state) override { this->parent_->set_interrupt_state(state); }
};
} // namespace esphome::bh1745
+66
View File
@@ -0,0 +1,66 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import (
CONF_BLUE_CHANNEL,
CONF_CLEAR_CHANNEL,
CONF_GREEN_CHANNEL,
CONF_RED_CHANNEL,
UNIT_COUNTS,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_COLOR_TEMPERATURE,
CONF_ILLUMINANCE,
DEVICE_CLASS_ILLUMINANCE,
ICON_LIGHTBULB,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_KELVIN,
UNIT_LUX,
)
from esphome.types import ConfigType
from . import CONF_BH1745_ID, BH1745Component
DEPENDENCIES = ["bh1745"]
color_channel_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_LIGHTBULB,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
cv.Optional(CONF_RED_CHANNEL): color_channel_schema,
cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema,
cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema,
cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema,
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
icon=ICON_LIGHTBULB,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_KELVIN,
icon=ICON_THERMOMETER,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BH1745_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor)
await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor)
await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor)
await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor)
await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor)
await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor)
+15 -8
View File
@@ -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)
+13 -14
View File
@@ -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);
+16 -26
View File
@@ -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
+13 -1
View File
@@ -116,13 +116,24 @@ void BL0939::received_package_(const DataPacket *data) const {
energy_sensor_sum_->publish_state(total_energy_consumption);
}
if (this->internal_temperature_sensor_ != nullptr) {
const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF;
// Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45.
const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f;
this->internal_temperature_sensor_->publish_state(temperature);
}
ESP_LOGV(TAG,
"BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh",
v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption);
}
void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity)
ESP_LOGCONFIG(TAG, "BL0939:");
ESP_LOGCONFIG(TAG,
"BL0939:\n"
" Mode: %s",
this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer")
: LOG_STR_LITERAL("direct"));
LOG_SENSOR("", "Voltage", this->voltage_sensor_);
LOG_SENSOR("", "Current 1", this->current_sensor_1_);
LOG_SENSOR("", "Current 2", this->current_sensor_2_);
@@ -131,6 +142,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit
LOG_SENSOR("", "Energy 1", this->energy_sensor_1_);
LOG_SENSOR("", "Energy 2", this->energy_sensor_2_);
LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_);
LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_);
}
uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; }
+35 -4
View File
@@ -8,13 +8,32 @@ namespace esphome::bl0939 {
// https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf
// (unfortunately chinese, but the formulas can be easily understood)
// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode.
// Direct mode.
// Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm)
// and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm)
// as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V)
static const float BL0939_IREF = 324004 * 1 / 1.218;
static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_IREF = 324004 * 1 / 1.218;
static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
// Current transformer mode.
// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V).
// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp.
// That is the same as a 0.3mOhm shunt in the direct mode formula.
// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on
// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2).
static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218;
static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000);
static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000);
static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000);
enum class BL0939Mode : uint8_t {
BL0939_MODE_DIRECT = 0,
BL0939_MODE_CURRENT_TRANSFORMER = 1,
};
struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align)
uint8_t l;
@@ -66,6 +85,16 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; }
void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; }
void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; }
void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; }
// Inline so a constant mode from codegen folds down to plain stores.
void set_mode(BL0939Mode mode) {
this->mode_ = mode;
const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER;
this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF;
this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF;
this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF;
this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF;
}
void loop() override;
@@ -84,6 +113,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
sensor::Sensor *energy_sensor_1_{nullptr};
sensor::Sensor *energy_sensor_2_{nullptr};
sensor::Sensor *energy_sensor_sum_{nullptr};
sensor::Sensor *internal_temperature_sensor_{nullptr};
// Divide by this to turn into Watt
float power_reference_ = BL0939_PREF;
@@ -93,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
float current_reference_ = BL0939_IREF;
// Divide by this to turn into kWh
float energy_reference_ = BL0939_EREF;
BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT};
static uint32_t to_uint32_t(ube24_t input);
+27
View File
@@ -3,14 +3,19 @@ from esphome.components import sensor, uart
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INTERNAL_TEMPERATURE,
CONF_MODE,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_KILOWATT_HOURS,
UNIT_VOLT,
UNIT_WATT,
@@ -29,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total"
bl0939_ns = cg.esphome_ns.namespace("bl0939")
BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice)
BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True)
MODE_OPTIONS = {
"direct": BL0939Mode.BL0939_MODE_DIRECT,
"current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER,
}
# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped
# when the config already equals it.
DEFAULT_MODE = "direct"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BL0939),
cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum(
MODE_OPTIONS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
@@ -82,6 +98,13 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -94,6 +117,9 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if (mode := config[CONF_MODE]) != DEFAULT_MODE:
cg.add(var.set_mode(mode))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT_1, var.set_current_sensor_1)
@@ -103,3 +129,4 @@ async def to_code(config: ConfigType) -> None:
await sensors(CONF_ENERGY_1, var.set_energy_sensor_1)
await sensors(CONF_ENERGY_2, var.set_energy_sensor_2)
await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum)
await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor)
+173 -22
View File
@@ -5,12 +5,16 @@
namespace esphome::bmi270 {
#ifdef USE_BMI270_BMM150
using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device definitions
#endif
ESPHOME_LOG_TAG(TAG, "bmi270");
#if defined(USE_ARDUINO) && !defined(USE_ESP32)
static const size_t MAX_I2C_BUFFER_SIZE = 32;
static constexpr size_t MAX_I2C_BUFFER_SIZE = 32;
#else
static const size_t MAX_I2C_BUFFER_SIZE = 256;
static constexpr size_t MAX_I2C_BUFFER_SIZE = 256;
#endif
// Configuration blob upload
@@ -19,12 +23,12 @@ static const size_t MAX_I2C_BUFFER_SIZE = 256;
bool BMI270Component::load_config_file_() {
// 1. Disable advanced power-save so the config port is accessible
if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00))
if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_ADV_POWER_SAVE_OFF))
return false;
delay(1);
// 2. Prepare config load: write 0x00 to INIT_CTRL to start
if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00))
if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_START))
return false;
// 3. Burst-write the config in pages
@@ -50,7 +54,7 @@ bool BMI270Component::load_config_file_() {
}
// 4. Signal end of config load
if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01))
if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_DONE))
return false;
delay(20); // spec: wait ≥20 ms for init to complete
@@ -58,7 +62,7 @@ bool BMI270Component::load_config_file_() {
uint8_t status = 0;
if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status))
return false;
if ((status & 0x0F) != 0x01) {
if ((status & BMI270_INTERNAL_STATUS_MASK) != BMI270_INTERNAL_STATUS_INIT_OK) {
ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status);
return false;
}
@@ -84,7 +88,7 @@ void BMI270Component::setup() {
ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id);
// 2. Soft-reset via CMD register (0x7E = 0xB6)
if (!this->write_byte(0x7E, 0xB6)) {
if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) {
this->mark_failed();
return;
}
@@ -100,7 +104,8 @@ void BMI270Component::setup() {
// 5. Configure accelerometer
// ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1)
uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7);
uint8_t acc_conf =
(uint8_t) (accel_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | BMI270_ACC_CONF_PERF_MODE;
if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) {
this->mark_failed();
return;
@@ -112,7 +117,8 @@ void BMI270Component::setup() {
// 6. Configure gyroscope
// GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1)
uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7);
uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) |
BMI270_GYR_CONF_NOISE_PERF | BMI270_GYR_CONF_FILTER_PERF;
if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) {
this->mark_failed();
return;
@@ -124,7 +130,7 @@ void BMI270Component::setup() {
// 7. Enable accelerometer, gyroscope, and temperature sensor
// PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1]
if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) {
if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU)) {
this->mark_failed();
return;
}
@@ -132,14 +138,133 @@ void BMI270Component::setup() {
// 8. Re-enable advanced power save (optional; keeps current low between reads)
// Disabled here for simplicity – leave in performance mode
if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup
if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup
this->mark_failed();
return;
}
// 9. Optionally bring up whatever is wired to the aux interface.
// This is entirely additive: it is off by default, and failure only disables
// magnetometer reporting rather than the whole component.
#ifdef USE_BMI270_BMM150
if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) {
this->magnetometer_ready_ = this->setup_magnetometer_();
if (!this->magnetometer_ready_) {
ESP_LOGW(TAG, "BMM150 magnetometer setup failed; continuing without it");
}
}
#endif
ESP_LOGCONFIG(TAG, "BMI270 initialised successfully");
}
#ifdef USE_BMI270_BMM150
bool BMI270Component::wait_for_aux_idle_(uint32_t timeout_ms) {
// The STATUS aux-busy bit is set while an aux transaction is in flight. Aux transaction
// duration depends on the attached device's own timing, so poll with a bounded
// timeout instead of guessing a fixed delay.
uint32_t start = millis();
while (true) {
uint8_t status = 0;
if (!this->read_byte(BMI270_REG_STATUS, &status))
return false;
if ((status & BMI270_STATUS_AUX_BUSY) == 0)
return true;
if (millis() - start >= timeout_ms)
return false;
delay(1);
}
}
bool BMI270Component::write_aux_register_(uint8_t reg, uint8_t value) {
if (!this->write_byte(BMI270_REG_AUX_WR_DATA, value))
return false;
if (!this->write_byte(BMI270_REG_AUX_WR_ADDR, reg))
return false;
return this->wait_for_aux_idle_(20);
}
bool BMI270Component::read_aux_block_(uint8_t reg, uint8_t *buf) {
if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, reg))
return false;
if (!this->wait_for_aux_idle_(20))
return false;
return this->read_bytes(BMI270_REG_AUX_X_LSB, buf, BMI270_AUX_DATA_LEN);
}
bool BMI270Component::read_magnetometer_trim_() {
uint8_t regs[BMM150_TRIM_BLOCKS * BMM150_TRIM_BLOCK_LEN];
for (uint8_t i = 0; i < BMM150_TRIM_BLOCKS; i++) {
if (!this->read_aux_block_(BMM150_REG_TRIM_START + i * BMM150_TRIM_BLOCK_LEN, regs + i * BMM150_TRIM_BLOCK_LEN))
return false;
}
this->mag_trim_ = bmm150_parse_trim(regs);
return true;
}
bool BMI270Component::setup_magnetometer_() {
// Sequence adapted from the Bosch BMI270 aux-interface protocol: enable the secondary
// I2C master in manual mode, point it at the BMM150's address, power the BMM150 up
// and soft-reset it, verify its chip ID, read its factory trim values, put it in
// normal/30Hz mode, then switch the aux interface to auto-burst mode so BMI270 keeps
// AUX_X/Y/Z/R refreshed from the BMM150's data registers.
ESP_LOGD(TAG, "Setting up BMM150 magnetometer via BMI270 aux interface...");
if (!this->write_byte(BMI270_REG_IF_CONF, BMI270_IF_CONF_AUX_IF_EN))
return false;
if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_MANUAL_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8))
return false;
if (!this->write_byte(BMI270_REG_AUX_DEV_ID, (uint8_t) (this->aux_device_address_ << 1)))
return false;
// Suspend -> sleep, then a soft reset so the BMM150 starts from a known state even if it
// stayed powered across a restart. The chip ID is only readable once the power bit is set.
if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_POWER_ON)) {
ESP_LOGW(TAG, "BMM150 power-on write timed out");
return false;
}
delay(BMM150_START_UP_TIME_MS);
if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_SOFT_RESET)) {
ESP_LOGW(TAG, "BMM150 soft-reset write timed out");
return false;
}
delay(BMM150_SOFT_RESET_TIME_MS);
uint8_t chip_id[BMI270_AUX_DATA_LEN];
if (!this->read_aux_block_(BMM150_REG_CHIP_ID, chip_id)) {
ESP_LOGW(TAG, "BMM150 chip-ID read failed");
return false;
}
if (chip_id[0] != BMM150_CHIP_ID_VALUE) {
ESP_LOGW(TAG, "BMM150 not detected (chip ID 0x%02X, expected 0x%02X)", chip_id[0], BMM150_CHIP_ID_VALUE);
return false;
}
if (!this->read_magnetometer_trim_()) {
ESP_LOGW(TAG, "BMM150 trim data read failed");
return false;
}
if (!this->write_aux_register_(BMM150_REG_OP_MODE, BMM150_CMD_NORMAL_MODE_ODR_30HZ)) {
ESP_LOGW(TAG, "BMM150 mode write timed out");
return false;
}
// Switch to auto-burst mode: BMI270 will keep polling 8 bytes from the BMM150's
// data registers (0x42) into its own AUX_X/Y/Z/R registers automatically.
if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_FCU_WRITE_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8 |
BMI270_AUX_IF_CONF_RD_BURST_8))
return false;
if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, BMM150_REG_DATA_X_LSB))
return false;
if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU | BMI270_PWR_CTRL_AUX_EN))
return false;
ESP_LOGD(TAG, "BMM150 magnetometer ready");
return true;
}
#endif // USE_BMI270_BMM150
void BMI270Component::dump_config() {
ESP_LOGCONFIG(TAG, "BMI270 IMU:");
LOG_I2C_DEVICE(this);
@@ -153,6 +278,12 @@ void BMI270Component::dump_config() {
ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]);
ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]);
#ifdef USE_BMI270_BMM150
if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) {
ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)",
this->magnetometer_ready_ ? LOG_STR_LITERAL("ready") : LOG_STR_LITERAL("NOT DETECTED"));
}
#endif
MotionComponent::dump_config();
}
@@ -163,9 +294,22 @@ bool BMI270Component::update_data(motion::MotionData &data) {
if (this->is_failed())
return false;
// Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb)
uint8_t raw_data[REG_READ_LEN];
if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) {
// Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb)
// When the magnetometer is active, the 8 aux data bytes (0x04–0x0B) sit directly before
// the accel data, so one burst read from AUX_X_LSB fetches both.
#ifdef USE_BMI270_BMM150
const bool read_mag = this->magnetometer_ready_ && !this->magnetometer_callback_.empty();
uint8_t buf[BMI270_AUX_DATA_LEN + REG_READ_LEN];
const uint8_t *mag_raw = buf;
const uint8_t *raw_data = read_mag ? buf + BMI270_AUX_DATA_LEN : buf;
const bool read_ok = read_mag ? this->read_bytes(BMI270_REG_AUX_X_LSB, buf, sizeof(buf))
: this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN);
#else
uint8_t buf[REG_READ_LEN];
const uint8_t *raw_data = buf;
const bool read_ok = this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN);
#endif
if (!read_ok) {
ESP_LOGW(TAG, "Failed to read IMU data");
return false;
}
@@ -195,14 +339,21 @@ bool BMI270Component::update_data(motion::MotionData &data) {
data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale;
data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale;
if (this->temperature_callback_.empty())
return true;
// Temperature: registers 0x22–0x23
// Formula from datasheet: T[°C] = raw / 512 + 23
static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8;
int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]);
float temperature = (raw_t / 512.0f) + 23.0f;
this->temperature_callback_.call(temperature);
if (!this->temperature_callback_.empty()) {
// Temperature: registers 0x22–0x23
// Formula from datasheet: T[°C] = raw / 512 + 23
static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8;
int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]);
float temperature = (raw_t / 512.0f) + 23.0f;
this->temperature_callback_.call(temperature);
}
#ifdef USE_BMI270_BMM150
if (read_mag) {
const BMM150Data mag_data = bmm150_convert(this->mag_trim_, mag_raw);
this->magnetometer_callback_.call(mag_data);
}
#endif
return true;
}
+98 -20
View File
@@ -4,35 +4,81 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/defines.h"
#ifdef USE_BMI270_BMM150
#include "bmm150_aux.h"
#endif
#include <functional>
namespace esphome::bmi270 {
// Register map
static const uint8_t BMI270_REG_CHIP_ID = 0x00;
static const uint8_t BMI270_REG_ERR_REG = 0x02;
static const uint8_t BMI270_REG_STATUS = 0x03;
static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB
static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB
static const uint8_t BMI270_REG_TEMP_0 = 0x22;
static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish)
static constexpr uint8_t BMI270_REG_CHIP_ID = 0x00;
static constexpr uint8_t BMI270_REG_ERR_REG = 0x02;
static constexpr uint8_t BMI270_REG_STATUS = 0x03;
static constexpr uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB
static constexpr uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB
static constexpr uint8_t BMI270_REG_TEMP_0 = 0x22;
static constexpr uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish)
static constexpr uint8_t REG_READ_LEN =
BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 +
1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4)
static const uint8_t BMI270_REG_PWR_CONF = 0x7C;
static const uint8_t BMI270_REG_PWR_CTRL = 0x7D;
static const uint8_t BMI270_REG_INIT_CTRL = 0x59;
static const uint8_t BMI270_REG_INIT_DATA = 0x5E;
static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B;
static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21;
static const uint8_t BMI270_REG_ACC_CONF = 0x40;
static const uint8_t BMI270_REG_ACC_RANGE = 0x41;
static const uint8_t BMI270_REG_GYR_CONF = 0x42;
static const uint8_t BMI270_REG_GYR_RANGE = 0x43;
static constexpr uint8_t BMI270_REG_PWR_CONF = 0x7C;
static constexpr uint8_t BMI270_REG_PWR_CTRL = 0x7D;
static constexpr uint8_t BMI270_REG_INIT_CTRL = 0x59;
static constexpr uint8_t BMI270_REG_INIT_DATA = 0x5E;
static constexpr uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B;
static constexpr uint8_t BMI270_REG_INTERNAL_STATUS = 0x21;
static constexpr uint8_t BMI270_REG_ACC_CONF = 0x40;
static constexpr uint8_t BMI270_REG_ACC_RANGE = 0x41;
static constexpr uint8_t BMI270_REG_GYR_CONF = 0x42;
static constexpr uint8_t BMI270_REG_GYR_RANGE = 0x43;
static const uint8_t BMI270_CHIP_ID_VALUE = 0x24;
static constexpr uint8_t BMI270_CHIP_ID_VALUE = 0x24;
// Auxiliary (secondary I2C master) interface registers.
// Used to optionally drive a BMM150 magnetometer wired to the BMI270's AUX pins
// instead of the main I2C bus - the BMM150 has no address of its own on that bus.
static constexpr uint8_t BMI270_REG_AUX_X_LSB = 0x04; // start of aux data: X,Y,Z,R (8 bytes)
static constexpr uint8_t BMI270_REG_AUX_DEV_ID = 0x4B;
static constexpr uint8_t BMI270_REG_AUX_IF_CONF = 0x4C;
static constexpr uint8_t BMI270_REG_AUX_RD_ADDR = 0x4D;
static constexpr uint8_t BMI270_REG_AUX_WR_ADDR = 0x4E;
static constexpr uint8_t BMI270_REG_AUX_WR_DATA = 0x4F;
static constexpr uint8_t BMI270_REG_IF_CONF = 0x6B;
static constexpr uint8_t BMI270_REG_CMD = 0x7E;
// Register values and bitfields
static constexpr uint8_t BMI270_CMD_SOFT_RESET = 0xB6;
static constexpr uint8_t BMI270_PWR_CONF_ADV_POWER_SAVE_OFF = 0x00;
static constexpr uint8_t BMI270_PWR_CONF_FIFO_SELF_WAKEUP = 0x02;
static constexpr uint8_t BMI270_INIT_CTRL_LOAD_START = 0x00;
static constexpr uint8_t BMI270_INIT_CTRL_LOAD_DONE = 0x01;
static constexpr uint8_t BMI270_INTERNAL_STATUS_MASK = 0x0F;
static constexpr uint8_t BMI270_INTERNAL_STATUS_INIT_OK = 0x01;
static constexpr uint8_t BMI270_CONF_BWP_NORMAL = 0x02; // filter bandwidth, bits 5:4
static constexpr uint8_t BMI270_CONF_BWP_SHIFT = 4;
static constexpr uint8_t BMI270_ACC_CONF_PERF_MODE = 0x80;
static constexpr uint8_t BMI270_GYR_CONF_NOISE_PERF = 0x40;
static constexpr uint8_t BMI270_GYR_CONF_FILTER_PERF = 0x80;
static constexpr uint8_t BMI270_STATUS_AUX_BUSY = 0x04;
static constexpr uint8_t BMI270_IF_CONF_AUX_IF_EN = 0x20;
static constexpr uint8_t BMI270_PWR_CTRL_AUX_EN = 0x01;
static constexpr uint8_t BMI270_PWR_CTRL_ACC_EN = 0x02;
static constexpr uint8_t BMI270_PWR_CTRL_GYR_EN = 0x04;
static constexpr uint8_t BMI270_PWR_CTRL_TEMP_EN = 0x08;
static constexpr uint8_t BMI270_PWR_CTRL_IMU =
BMI270_PWR_CTRL_ACC_EN | BMI270_PWR_CTRL_GYR_EN | BMI270_PWR_CTRL_TEMP_EN;
static constexpr uint8_t BMI270_AUX_IF_CONF_MANUAL_EN = 0x80;
static constexpr uint8_t BMI270_AUX_IF_CONF_FCU_WRITE_EN = 0x40;
static constexpr uint8_t BMI270_AUX_IF_CONF_MAN_RD_BURST_8 = 0x0C; // bits 3:2 = 3
static constexpr uint8_t BMI270_AUX_IF_CONF_RD_BURST_8 = 0x03; // bits 1:0 = 3
// AUX_X_LSB..AUX_R_MSB (8 bytes) sit directly before the accel data, so one burst can read both.
static constexpr uint8_t BMI270_AUX_DATA_LEN = 8;
static_assert(BMI270_REG_AUX_X_LSB + BMI270_AUX_DATA_LEN == BMI270_REG_DATA_8, "aux data must precede accel data");
// Accelerometer range options
enum BMI270AccelRange : uint8_t {
@@ -75,7 +121,14 @@ enum BMI270GyroODR : uint8_t {
BMI270_GYRO_ODR_3200 = 0x0D,
};
// ---Data class
#ifdef USE_BMI270_BMM150
// What is wired to this instance's auxiliary (secondary I2C master) interface; another
// instance in the same build may have nothing attached.
enum BMI270AuxDevice : uint8_t {
BMI270_AUX_DEVICE_NONE = 0,
BMI270_AUX_DEVICE_BMM150 = 1,
};
#endif
// Main component class
class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice {
@@ -91,18 +144,43 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev
void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; }
void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; }
template<typename F> void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward<F>(cb)); }
#ifdef USE_BMI270_BMM150
void set_aux_device(BMI270AuxDevice device) { this->aux_device_ = device; }
void set_aux_device_address(uint8_t address) { this->aux_device_address_ = address; }
template<typename F> void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward<F>(cb)); }
#endif
protected:
bool update_data(motion::MotionData &data) override;
bool load_config_file_();
#ifdef USE_BMI270_BMM150
// Brings up the BMM150 over the BMI270's aux interface. Optional: on failure this only
// disables magnetometer reporting, it does not fail the whole component (accel/gyro still work).
bool setup_magnetometer_();
// Blocking poll of the aux-transaction-busy bit; aux transaction time depends on the
// attached device, so this is bounded by retries/timeout rather than a fixed delay.
bool wait_for_aux_idle_(uint32_t timeout_ms);
// Manual-mode aux read of 8 bytes starting at `reg` on the aux device.
bool read_aux_block_(uint8_t reg, uint8_t *buf);
// Manual-mode aux write of one byte to `reg` on the aux device.
bool write_aux_register_(uint8_t reg, uint8_t value);
// Reads the BMM150 factory trim values used to compensate its samples.
bool read_magnetometer_trim_();
#endif
// Config
BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G};
BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100};
BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000};
BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200};
LazyCallbackManager<void(float)> temperature_callback_{};
#ifdef USE_BMI270_BMM150
LazyCallbackManager<void(const bmm150::BMM150Data &)> magnetometer_callback_{};
bmm150::BMM150Trim mag_trim_{};
BMI270AuxDevice aux_device_{BMI270_AUX_DEVICE_NONE};
uint8_t aux_device_address_{bmm150::BMM150_DEFAULT_I2C_ADDRESS};
bool magnetometer_ready_{false};
#endif
};
} // namespace esphome::bmi270
+123
View File
@@ -0,0 +1,123 @@
#pragma once
#include <cmath>
#include <cstdint>
#include "esphome/core/helpers.h"
// BMM150 magnetometer definitions - the chip that can optionally be wired to the BMI270's
// auxiliary (secondary I2C master) interface. Lives in its own header/namespace because this
// is BMM150-specific driver knowledge, not BMI270 knowledge - it just can't be a standalone
// ESPHome component, since the BMM150 has no address of its own on the main I2C bus and is only
// reachable by having the BMI270 proxy register reads/writes through its aux interface.
namespace esphome::bmi270::bmm150 {
static constexpr uint8_t BMM150_DEFAULT_I2C_ADDRESS = 0x10;
static constexpr uint8_t BMM150_REG_CHIP_ID = 0x40;
static constexpr uint8_t BMM150_REG_DATA_X_LSB = 0x42;
static constexpr uint8_t BMM150_REG_POWER_CONTROL = 0x4B;
static constexpr uint8_t BMM150_REG_OP_MODE = 0x4C;
static constexpr uint8_t BMM150_CHIP_ID_VALUE = 0x32;
static constexpr uint8_t BMM150_CMD_POWER_ON = 0x01; // power control bit: suspend -> sleep (3 ms start-up)
static constexpr uint8_t BMM150_CMD_SOFT_RESET = 0x83; // soft reset bits 7 and 1, power control bit kept set
static constexpr uint8_t BMM150_CMD_NORMAL_MODE_ODR_30HZ = 0x38; // data rate 111b (30 Hz), opmode 00b (normal)
static constexpr uint32_t BMM150_START_UP_TIME_MS = 3;
static constexpr uint32_t BMM150_SOFT_RESET_TIME_MS = 1;
// Factory trim registers 0x5D-0x71, read once at setup and used to compensate every sample.
static constexpr uint8_t BMM150_REG_TRIM_START = 0x5D;
static constexpr uint8_t BMM150_TRIM_BLOCK_LEN = 8; // one aux burst
static constexpr uint8_t BMM150_TRIM_BLOCKS = 3; // 0x5D, 0x65, 0x6D -> covers 0x5D-0x74
static constexpr uint8_t BMM150_TRIM_X1 = 0x5D;
static constexpr uint8_t BMM150_TRIM_Y1 = 0x5E;
static constexpr uint8_t BMM150_TRIM_Z4 = 0x62; // 16-bit, LSB first
static constexpr uint8_t BMM150_TRIM_X2 = 0x64;
static constexpr uint8_t BMM150_TRIM_Y2 = 0x65;
static constexpr uint8_t BMM150_TRIM_Z2 = 0x68; // 16-bit
static constexpr uint8_t BMM150_TRIM_Z1 = 0x6A; // 16-bit
static constexpr uint8_t BMM150_TRIM_XYZ1 = 0x6C; // 16-bit, 15 valid bits
static constexpr uint8_t BMM150_TRIM_Z3 = 0x6E; // 16-bit
static constexpr uint8_t BMM150_TRIM_XY2 = 0x70;
static constexpr uint8_t BMM150_TRIM_XY1 = 0x71;
static constexpr uint16_t BMM150_TRIM_XYZ1_MASK = 0x7FFF;
// Raw ADC values that mean "overflow" per the datasheet.
static constexpr int16_t BMM150_XY_OVERFLOW_ADCVAL = -4096;
static constexpr int16_t BMM150_Z_OVERFLOW_ADCVAL = -16384;
// Result of a BMM150 magnetometer reading, in µT. An axis is NaN when the sensor reported overflow.
struct BMM150Data {
float x;
float y;
float z;
};
// Factory trim values (BMM150 datasheet, compensation section).
struct BMM150Trim {
int8_t x1, y1, x2, y2, xy2;
uint8_t xy1;
int16_t z2, z3, z4;
uint16_t z1, xyz1;
};
// A little-endian 16-bit trim word, addressed by its register
inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) {
const uint8_t offset = reg - BMM150_REG_TRIM_START;
return encode_uint16(regs[offset + 1], regs[offset]);
}
// Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21).
inline BMM150Trim bmm150_parse_trim(const uint8_t *regs) {
BMM150Trim trim;
trim.x1 = static_cast<int8_t>(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]);
trim.y1 = static_cast<int8_t>(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]);
trim.z4 = static_cast<int16_t>(bmm150_trim_word(regs, BMM150_TRIM_Z4));
trim.x2 = static_cast<int8_t>(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]);
trim.y2 = static_cast<int8_t>(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]);
trim.z2 = static_cast<int16_t>(bmm150_trim_word(regs, BMM150_TRIM_Z2));
trim.z1 = bmm150_trim_word(regs, BMM150_TRIM_Z1);
trim.xyz1 = bmm150_trim_word(regs, BMM150_TRIM_XYZ1) & BMM150_TRIM_XYZ1_MASK;
trim.z3 = static_cast<int16_t>(bmm150_trim_word(regs, BMM150_TRIM_Z3));
trim.xy2 = static_cast<int8_t>(regs[BMM150_TRIM_XY2 - BMM150_REG_TRIM_START]);
trim.xy1 = regs[BMM150_TRIM_XY1 - BMM150_REG_TRIM_START];
return trim;
}
// Compensates an X or Y sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant).
inline float bmm150_compensate_xy(const BMM150Trim &trim, int8_t trim_a, int8_t trim_b, int16_t raw, uint16_t rhall) {
if (raw == BMM150_XY_OVERFLOW_ADCVAL || rhall == 0 || trim.xyz1 == 0)
return NAN;
float retval = (float) trim.xyz1 * 16384.0f / (float) rhall - 16384.0f;
float comp1 = (float) trim.xy2 * (retval * retval / 268435456.0f);
float comp2 = comp1 + retval * (float) trim.xy1 / 16384.0f;
float comp3 = (float) trim_b + 160.0f;
float comp4 = (float) raw * ((comp2 + 256.0f) * comp3);
return ((comp4 / 8192.0f) + (float) trim_a * 8.0f) / 16.0f;
}
// Compensates a Z sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant).
inline float bmm150_compensate_z(const BMM150Trim &trim, int16_t raw, uint16_t rhall) {
if (raw == BMM150_Z_OVERFLOW_ADCVAL || trim.z2 == 0 || trim.z1 == 0 || trim.xyz1 == 0 || rhall == 0)
return NAN;
float comp0 = (float) raw - (float) trim.z4;
float comp1 = (float) rhall - (float) trim.xyz1;
float comp2 = (float) trim.z3 * comp1;
float comp3 = (float) trim.z1 * (float) rhall / 32768.0f;
float comp4 = (float) trim.z2 + comp3;
float comp5 = comp0 * 131072.0f - comp2;
return (comp5 / (comp4 * 4.0f)) / 16.0f;
}
// Converts 8 raw bytes from the BMM150 data registers (X, Y, Z, RHALL; 0x42-0x49) to compensated µT.
inline BMM150Data bmm150_convert(const BMM150Trim &trim, const uint8_t *raw) {
// X/Y are 13-bit (LSB byte bits 7:3), Z is 15-bit (bits 7:1), RHALL is 14-bit (bits 7:2).
int16_t x = (int16_t) (((int16_t) (int8_t) raw[1] * 32) | (raw[0] >> 3));
int16_t y = (int16_t) (((int16_t) (int8_t) raw[3] * 32) | (raw[2] >> 3));
int16_t z = (int16_t) (((int16_t) (int8_t) raw[5] * 128) | (raw[4] >> 1));
uint16_t rhall = (uint16_t) (((uint16_t) raw[7] << 6) | (raw[6] >> 2));
return BMM150Data{
.x = bmm150_compensate_xy(trim, trim.x1, trim.x2, x, rhall),
.y = bmm150_compensate_xy(trim, trim.y1, trim.y2, y, rhall),
.z = bmm150_compensate_z(trim, z, rhall),
};
}
} // namespace esphome::bmi270::bmm150
+17
View File
@@ -14,7 +14,11 @@ from . import BMI270Component, bmi270_ns
DEPENDENCIES = ["i2c"]
CONF_AUX_ADDRESS = "aux_address"
CONF_AUX_DEVICE = "aux_device"
# Enum proxies (must match the C++ enum values exactly)
BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice")
BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange")
ACCEL_RANGE_OPTIONS = {
"2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G,
@@ -73,6 +77,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum(
GYRO_ODR_OPTIONS, upper=True
),
# Optional device wired to the BMI270's aux interface (not the main I2C bus).
# Defaults to NONE so existing configs/boards are unaffected.
cv.Optional(CONF_AUX_DEVICE, default="NONE"): cv.one_of(
"NONE", "BMM150", upper=True
),
# Default matches the BMM150's fixed I2C address; irrelevant when aux_device is NONE.
cv.Optional(CONF_AUX_ADDRESS, default=0x10): cv.i2c_address,
}
)
.extend(i2c.i2c_device_schema(0x68))
@@ -90,3 +101,9 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR]))
cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE]))
cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR]))
# The aux interface code only compiles when some instance has a BMM150; the
# instance still records its own device, as another one may have none
if config[CONF_AUX_DEVICE] == "BMM150":
cg.add_define("USE_BMI270_BMM150")
cg.add(var.set_aux_device(BMI270AuxDevice.BMI270_AUX_DEVICE_BMM150))
cg.add(var.set_aux_device_address(config[CONF_AUX_ADDRESS]))
+74 -14
View File
@@ -6,37 +6,97 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
DEVICE_CLASS_TEMPERATURE,
ICON_MAGNET,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_MICROTESLA,
)
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import CONF_BMI270_ID, BMI270Component
from . import CONF_BMI270_ID, BMI270Component, bmi270_ns
from .motion import CONF_AUX_DEVICE
AUTO_LOAD = ["bmi270"]
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(
CONF_MAGNETIC_FIELD_X = "magnetic_field_x"
CONF_MAGNETIC_FIELD_Y = "magnetic_field_y"
CONF_MAGNETIC_FIELD_Z = "magnetic_field_z"
BMM150Data = bmi270_ns.namespace("bmm150").class_("BMM150Data")
_MAGNETIC_FIELDS = (CONF_MAGNETIC_FIELD_X, CONF_MAGNETIC_FIELD_Y, CONF_MAGNETIC_FIELD_Z)
def _parent_schema() -> cv.Schema:
return cv.Schema({cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component)})
def _temperature_sensor_schema() -> cv.Schema:
return 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(_parent_schema())
def _magnetic_field_sensor_schema() -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_MICROTESLA,
icon=ICON_MAGNET,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
).extend(_parent_schema())
# `type` defaults to temperature so existing configs that omit it keep working unchanged.
CONFIG_SCHEMA = cv.typed_schema(
{
cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component),
}
CONF_TEMPERATURE: _temperature_sensor_schema(),
**{x: _magnetic_field_sensor_schema() for x in _MAGNETIC_FIELDS},
},
default_type=CONF_TEMPERATURE,
)
def _final_validate(config: ConfigType) -> None:
if config[CONF_TYPE] not in _MAGNETIC_FIELDS:
return
full_config = fv.full_config.get()
bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1]
bmi270_config = full_config.get_config_for_path(bmi270_path)
if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150":
raise cv.Invalid(
"This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'",
path=[CONF_TYPE],
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
sensor_type = config[CONF_TYPE]
var = await sensor.new_sensor(config)
parent = await cg.get_variable(config[CONF_BMI270_ID])
if sensor_type == CONF_TEMPERATURE:
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))
return
axis = sensor_type[-1:]
data = MockObj("data")
value_lambda = await cg.process_lambda(
var.publish_state(data),
[(cg.float_, str(data))],
var.publish_state(getattr(data, axis)),
[(BMM150Data.operator("const").operator("ref"), str(data))],
)
cg.add(parent.add_temperature_listener(value_lambda))
cg.add(parent.add_magnetometer_listener(value_lambda))
+377
View File
@@ -0,0 +1,377 @@
from collections.abc import Callable
from typing import Any
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, CONF_UPDATE_INTERVAL, CONF_WATCHDOG
from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@Rapsssito"]
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"
CONF_CHARGE_ENABLED = "charge_enabled"
CONF_FLASH_CHARGING_MODE = "flash_charging_mode"
CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm"
CONF_TERMINATION_PERCENT = "termination_percent"
CONF_VINDPM_MODE = "vindpm_mode"
CONF_THERMAL_REGULATION = "thermal_regulation"
CONF_BATTERY_OCP_LIMIT = "battery_ocp_limit"
CONF_BATTERY_UVLO = "battery_uvlo"
CONF_MASK_CHARGE_STATUS_INTERRUPT = "mask_charge_status_interrupt"
CONF_MASK_ILIM_INTERRUPT = "mask_ilim_interrupt"
CONF_MASK_VINDPM_INTERRUPT = "mask_vindpm_interrupt"
CONF_TS_AUTO_FUNCTION = "ts_auto_function"
CONF_VLOWV = "vlowv"
CONF_RECHARGE_THRESHOLD = "recharge_threshold"
CONF_DOUBLE_TIMER_DURING_DPM = "double_timer_during_dpm"
CONF_SAFETY_TIMER = "safety_timer"
CONF_LONG_PRESS_DURATION = "long_press_duration"
CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin"
CONF_AUTOWAKE = "autowake"
CONF_INPUT_CURRENT_LIMIT = "input_current_limit"
CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action"
CONF_WAKE1_TIMER = "wake1_timer"
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"
CONF_TS_WARM_DISABLE = "ts_warm_disable"
CONF_TS_COOL_DISABLE = "ts_cool_disable"
CONF_TS_ICHG = "ts_ichg"
CONF_TS_VRCG = "ts_vrcg"
CONF_MASK_TS_INTERRUPT = "mask_ts_interrupt"
CONF_MASK_TREG_INTERRUPT = "mask_treg_interrupt"
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.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
REG_CHARGECTRL0 = 0x05
REG_CHARGECTRL1 = 0x06
REG_IC_CTRL = 0x07
REG_TMR_ILIM = 0x08
REG_SHIP_RST = 0x09
REG_SYS_REG = 0x0A
REG_TS_CONTROL = 0x0B
REG_MASK_ID = 0x0C
REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1
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}
THERM_REGULATION_OPTIONS = {"100c": 0, "80c": 1, "60c": 2, "disabled": 3}
BATTERY_OCP_LIMIT_OPTIONS = {"500ma": 0, "1000ma": 1, "1500ma": 2, "3000ma": 3}
BATTERY_UVLO_OPTIONS = {
"3.0v": 0,
"2.8v": 3,
"2.6v": 4,
"2.4v": 5,
"2.2v": 6,
"2.0v": 7,
}
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 = {
"50ma": 0,
"100ma": 1,
"200ma": 2,
"300ma": 3,
"400ma": 4,
"500ma": 5,
"665ma": 6,
"1050ma": 7,
}
PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS = {
"none": 0,
"hardware_reset": 1,
"ship_mode": 2,
"shutdown_mode": 3,
}
WAKE1_TIMER_OPTIONS = {"300ms": 0, "1s": 1}
WAKE2_TIMER_OPTIONS = {"2s": 0, "3s": 1}
SYSTEM_REGULATION_OPTIONS = {
"battery_tracking": 0,
"4.4v": 1,
"4.5v": 2,
"4.6v": 3,
"4.7v": 4,
"4.8v": 5,
"4.9v": 6,
"pass_through": 7,
}
SYS_MODE_OPTIONS = {
"normal": 0,
"battery_only": 1,
"floating_off": 2,
"pulldown_off": 3,
}
TS_HOT_OPTIONS = {"60c": 0, "65c": 1, "50c": 2, "45c": 3}
TS_COLD_OPTIONS = {"0c": 0, "3c": 1, "5c": 2, "-3c": 3}
TS_ICHG_OPTIONS = {"0.5x": 0, "0.2x": 1}
TS_VRCG_OPTIONS = {"-100mv": 0, "-200mv": 1}
def _on_voltage_step(volts: float) -> float:
millivolts = volts * 1000
if abs(millivolts - round(millivolts / 10) * 10) > 1e-6:
raise cv.Invalid("battery_regulation_voltage must be a multiple of 10 mV")
return volts
def _on_current_step(amps: float) -> float:
milliamps = amps * 1000
step = 1 if milliamps < 36 else 10
if abs(milliamps - round(milliamps / step) * step) > 1e-6 or 35 < milliamps < 40:
raise cv.Invalid(
"fast_charge_current must be a whole number of mA up to 35mA, then a multiple of 10 mA"
)
return amps
# (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; the PG/GPO
# switch drives them.
FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
(
CONF_BATTERY_REGULATION_VOLTAGE,
REG_VBAT_CTRL,
0,
cv.All(cv.voltage, cv.Range(min=3.5, max=4.65), _on_voltage_step),
"4.2V",
),
(CONF_CHARGE_ENABLED, REG_ICHG_CTRL, 7, cv.boolean, True),
(
CONF_FAST_CHARGE_CURRENT,
REG_ICHG_CTRL,
0,
cv.All(cv.current, cv.Range(min=0.005, max=1.0), _on_current_step),
"10mA",
),
(CONF_FLASH_CHARGING_MODE, REG_CHARGECTRL0, 7, cv.boolean, False),
(CONF_PRECHARGE_IS_ITERM, REG_CHARGECTRL0, 6, cv.boolean, False),
(CONF_TERMINATION_PERCENT, REG_CHARGECTRL0, 4, TERMINATION_PERCENT_OPTIONS, "10%"),
(CONF_VINDPM_MODE, REG_CHARGECTRL0, 2, VINDPM_MODE_OPTIONS, "4.5v"),
(CONF_THERMAL_REGULATION, REG_CHARGECTRL0, 0, THERM_REGULATION_OPTIONS, "100c"),
(CONF_BATTERY_OCP_LIMIT, REG_CHARGECTRL1, 6, BATTERY_OCP_LIMIT_OPTIONS, "3000ma"),
(CONF_BATTERY_UVLO, REG_CHARGECTRL1, 3, BATTERY_UVLO_OPTIONS, "3.0v"),
(CONF_MASK_CHARGE_STATUS_INTERRUPT, REG_CHARGECTRL1, 2, cv.boolean, True),
(CONF_MASK_ILIM_INTERRUPT, REG_CHARGECTRL1, 1, cv.boolean, True),
(CONF_MASK_VINDPM_INTERRUPT, REG_CHARGECTRL1, 0, cv.boolean, False),
(CONF_TS_AUTO_FUNCTION, REG_IC_CTRL, 7, cv.boolean, True),
(CONF_VLOWV, REG_IC_CTRL, 6, VLOWV_OPTIONS, "3.0v"),
(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"),
(CONF_INPUT_CURRENT_LIMIT, REG_TMR_ILIM, 0, INPUT_CURRENT_LIMIT_OPTIONS, "500ma"),
(
CONF_PUSH_BUTTON_LONG_PRESS_ACTION,
REG_SHIP_RST,
3,
PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS,
"ship_mode",
),
(CONF_WAKE1_TIMER, REG_SHIP_RST, 2, WAKE1_TIMER_OPTIONS, "300ms"),
(CONF_WAKE2_TIMER, REG_SHIP_RST, 1, WAKE2_TIMER_OPTIONS, "2s"),
(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"),
(CONF_TS_WARM_DISABLE, REG_TS_CONTROL, 3, cv.boolean, False),
(CONF_TS_COOL_DISABLE, REG_TS_CONTROL, 2, cv.boolean, False),
(CONF_TS_ICHG, REG_TS_CONTROL, 1, TS_ICHG_OPTIONS, "0.5x"),
(CONF_TS_VRCG, REG_TS_CONTROL, 0, TS_VRCG_OPTIONS, "-100mv"),
(CONF_MASK_TS_INTERRUPT, REG_MASK_ID, 7, cv.boolean, False),
(CONF_MASK_TREG_INTERRUPT, REG_MASK_ID, 6, cv.boolean, True),
(CONF_MASK_BAT_INTERRUPT, REG_MASK_ID, 5, cv.boolean, False),
(CONF_MASK_PG_INTERRUPT, REG_MASK_ID, 4, cv.boolean, False),
)
def _battery_regulation_code(volts: float) -> int:
"""VBAT_CTRL[6:0]: 3.5 V plus 10 mV per step."""
return (round(volts * 1000) - 3500) // 10
def _fast_charge_code(amps: float) -> int:
"""ICHG_CTRL[6:0]: 1 mA steps from 5 mA to 35 mA, then 10 mA steps from 40 mA."""
milliamps = round(amps * 1000)
if milliamps <= 35:
return milliamps - 5
return milliamps // 10 + 27
_ENCODERS: dict[str, Callable[[Any], int]] = {
CONF_BATTERY_REGULATION_VOLTAGE: _battery_regulation_code,
CONF_FAST_CHARGE_CURRENT: _fast_charge_code,
CONF_CHARGE_ENABLED: lambda enabled: not enabled,
}
def pack_registers(config: ConfigType) -> list[int]:
"""Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes."""
regs = [0] * REGISTER_COUNT
for key, register, shift, validator, _ in FIELDS:
value = config[key]
if isinstance(validator, dict):
value = validator[value]
elif (encode := _ENCODERS.get(key)) is not None:
value = encode(value)
regs[register - REG_VBAT_CTRL] |= int(value) << shift
return regs
def _field_schema(with_defaults: bool) -> cv.Schema:
schema = {}
for key, _, _, validator, default in FIELDS:
if isinstance(validator, dict):
validator = cv.enum(validator, lower=True)
optional = (
cv.Optional(key, default=default) if with_defaults else cv.Optional(key)
)
schema[optional] = validator
return cv.Schema(schema)
BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True)
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),
cv.Optional(CONF_CONFIGURE_ON_BOOT, default=True): cv.boolean,
}
)
.extend(BQ25186_CONFIG_SCHEMA)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x6A)),
_check_watchdog,
)
CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(BQ25186Component),
}
).extend(_field_schema(with_defaults=False))
)
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)
if config[CONF_CONFIGURE_ON_BOOT]:
cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config))))
@automation.register_action(
"bq25186.apply_configuration",
automation.LambdaAction,
CONFIGURE_ACTION_SCHEMA,
synchronous=True,
)
async def bq25186_configure_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> cg.MockObj:
parent = await cg.get_variable(config[CONF_ID])
# 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)
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)
return automation.new_lambda_pvariable(
action_id, lambda_, automation.StatelessLambdaAction, template_arg
)
@@ -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
)
+175
View File
@@ -0,0 +1,175 @@
#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"
namespace esphome::bq25186 {
ESPHOME_LOG_TAG(TAG, "bq25186");
// 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 = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0,
};
bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) {
if (!this->write_byte(reg, value)) {
ESP_LOGW(TAG, "Failed to write register 0x%02X", reg);
return false;
}
return true;
}
bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) {
uint8_t reg_value;
if (!this->read_byte(reg, &reg_value)) {
ESP_LOGW(TAG, "Failed to read register 0x%02X", reg);
return false;
}
reg_value = (reg_value & ~mask) | (value & mask);
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 &regs) 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 &regs) {
// 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 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;
}
}
this->status_clear_warning();
return true;
}
void BQ25186Component::setup() {
uint8_t mask_id;
if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Another charger at this address could lay its registers out differently, so do not write into it
const uint8_t device_id = mask_id & 0x0F;
if (device_id != 0x01) {
ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id);
this->mark_failed(LOG_STR("Not a BQ25186"));
return;
}
// 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->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
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#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;
static constexpr uint8_t BQ25186_REG_CHARGECTRL1 = 0x06;
static constexpr uint8_t BQ25186_REG_IC_CTRL = 0x07;
static constexpr uint8_t BQ25186_REG_TMR_ILIM = 0x08;
static constexpr uint8_t BQ25186_REG_SHIP_RST = 0x09;
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 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 &regs) {
this->regs_ = regs;
this->configure_on_boot_ = true;
}
bool apply_config(const BQ25186ConfigRegisters &regs);
#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 &regs) 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);
#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
+52
View File
@@ -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])
+27
View File
@@ -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)
+38
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@rggammon"]
DOMAIN = "bq27220"
+93
View File
@@ -0,0 +1,93 @@
#include "bq27220.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::bq27220 {
ESPHOME_LOG_TAG(TAG, "bq27220");
// Standard command registers (16-bit, little-endian), SLUUBD4A
static constexpr uint8_t REG_CONTROL = 0x00;
static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K
static constexpr uint8_t REG_VOLTAGE = 0x08; // mV
static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed
static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh
static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh
static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging
static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // %
static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // %
// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer
static constexpr uint8_t REG_MAC_DATA = 0x40;
static constexpr uint16_t DEVICE_NUMBER = 0x0220;
static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF;
bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) {
uint8_t data[2];
if (this->read_register(reg, data, 2) != i2c::ERROR_OK) {
return false;
}
value = encode_uint16(data[1], data[0]);
return true;
}
void BQ27220Component::setup() {
const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian
if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) {
this->mark_failed(LOG_STR("Failed to communicate with BQ27220"));
return;
}
delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read
uint16_t device_number = 0;
if (!this->read_word_(REG_MAC_DATA, device_number)) {
this->mark_failed(LOG_STR("Failed to read device number from BQ27220"));
return;
}
if (device_number != DEVICE_NUMBER) {
ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER);
this->mark_failed();
}
}
void BQ27220Component::update() {
bool ok = true;
this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; });
this->publish_(this->current_sensor_, REG_CURRENT, ok,
[](uint16_t raw) { return static_cast<int16_t>(raw) * 0.001f; });
this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); });
this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; });
this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); });
this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok,
[](uint16_t raw) { return float(raw); });
this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok,
[](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); });
this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); });
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220"));
}
}
void BQ27220Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Current", this->current_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_);
LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_);
LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_);
LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_);
}
} // namespace esphome::bq27220
+44
View File
@@ -0,0 +1,44 @@
#pragma once
// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf
// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::bq27220 {
class BQ27220Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(voltage)
SUB_SENSOR(current)
SUB_SENSOR(battery_level)
SUB_SENSOR(temperature)
SUB_SENSOR(remaining_capacity)
SUB_SENSOR(full_charge_capacity)
SUB_SENSOR(time_to_empty)
SUB_SENSOR(state_of_health)
public:
void setup() override;
void update() override;
void dump_config() override;
protected:
bool read_word_(uint8_t reg, uint16_t &value);
/// Read a standard command register and publish it through the sensor, if one is configured.
/// A failed read publishes NAN and clears `ok`.
template<typename F> void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) {
if (sensor == nullptr)
return;
uint16_t raw;
if (!this->read_word_(reg, raw)) {
ok = false;
sensor->publish_state(NAN);
return;
}
sensor->publish_state(convert(raw));
}
};
} // namespace esphome::bq27220
+123
View File
@@ -0,0 +1,123 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import UNIT_MILLIAMPERE_HOUR
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_CURRENT,
CONF_ID,
CONF_TEMPERATURE,
CONF_VOLTAGE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_BATTERY,
STATE_CLASS_MEASUREMENT,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_MINUTE,
UNIT_PERCENT,
UNIT_VOLT,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
bq27220_ns = cg.esphome_ns.namespace("bq27220")
BQ27220Component = bq27220_ns.class_(
"BQ27220Component", cg.PollingComponent, i2c.I2CDevice
)
CONF_REMAINING_CAPACITY = "remaining_capacity"
CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity"
CONF_TIME_TO_EMPTY = "time_to_empty"
CONF_STATE_OF_HEALTH = "state_of_health"
# Other SLUUBD4A standard commands are intentionally not exposed to keep the
# component focused; they can be added later if there is demand: TimeToFull
# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24),
# DesignCapacity (0x3C).
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ27220Component),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
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,
),
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,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema(
unit_of_measurement=UNIT_MILLIAMPERE_HOUR,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_BATTERY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema(
unit_of_measurement=UNIT_MILLIAMPERE_HOUR,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_BATTERY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema(
unit_of_measurement=UNIT_MINUTE,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_BATTERY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x55))
)
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)
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor)
await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor)
await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor)
await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor)
+2 -26
View File
@@ -4,7 +4,6 @@ import esphome.codegen as cg
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.components.wifi import DOMAIN as WIFI_DOMAIN
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
@@ -16,7 +15,6 @@ from esphome.const import (
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -78,17 +76,7 @@ def _final_validate(config: ConfigType) -> None:
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
# Register socket needs for DNS server and additional HTTP connections
# - 1 UDP socket for DNS server
# - 3 TCP sockets for captive portal detection probes + configuration requests
# OS captive portal detection makes multiple probe requests that stay in TIME_WAIT.
# Need headroom for actual user configuration requests.
# LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts.
# The listening socket is registered by web_server_base (shared HTTP server).
from esphome.components import socket
socket.consume_sockets(3, "captive_portal")(config)
socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config)
web_server_base.consume_captive_dns_sockets(config, DOMAIN)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -108,16 +96,4 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
cg.add_library("DNSServer", None)
# Only compile the ESP-IDF DNS server when using ESP-IDF framework
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"dns_server_esp32_idf.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
}
)
web_server_base.add_captive_dns_library()
@@ -6,9 +6,6 @@
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/scan_list.h"
#include "esphome/components/wifi/wifi_component.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#include "captive_index.h"
namespace esphome::captive_portal {
@@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
void CaptivePortal::setup() {
// Disable loop by default - will be enabled when captive portal starts
this->disable_loop();
#ifdef USE_PROVISIONING
// The captive portal is a provisioning surface: once the provisioning window
// has closed, stop serving it. WiFi's own closed-callback shuts down the
// access point the portal runs on, and the gated fallback in WiFiComponent's
// loop() ensures neither is started again afterwards.
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
if (this->active_) {
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
this->end();
}
});
}
#endif
}
void CaptivePortal::start() {
this->base_->init();
@@ -102,17 +85,7 @@ void CaptivePortal::start() {
this->base_->add_handler_without_auth(this);
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
#if defined(USE_ESP32)
// Create DNS server instance for ESP-IDF
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip());
this->initialized_ = true;
this->active_ = true;
@@ -1,16 +1,11 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_CAPTIVE_PORTAL
#include <memory>
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/components/web_server_base/captive_dns.h"
namespace esphome::captive_portal {
@@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
CaptivePortal(web_server_base::WebServerBase *base);
void setup() override;
void dump_config() override;
void loop() override {
#if defined(USE_ESP32)
if (this->dns_server_ != nullptr) {
this->dns_server_->process_next_request();
}
#elif defined(USE_ARDUINO)
if (this->dns_server_ != nullptr) {
this->dns_server_->processNextRequest();
}
#endif
}
void loop() override { this->dns_.loop(); }
float get_setup_priority() const override;
void start();
bool is_active() const { return this->active_; }
@@ -37,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
this->base_->deinit();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
this->dns_.stop();
}
bool canHandle(AsyncWebServerRequest *request) const override {
@@ -60,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
web_server_base::WebServerBase *base_;
bool initialized_{false};
bool active_{false};
#if defined(USE_ARDUINO) || defined(USE_ESP32)
std::unique_ptr<DNSServer> dns_server_{nullptr};
#endif
web_server_base::CaptiveDNS dns_;
};
extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+13 -30
View File
@@ -46,7 +46,6 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
CONF_TEMPERATURE_STEP,
CONF_TRIGGER_ID,
CONF_VISUAL,
CONF_WEB_SERVER,
)
@@ -164,14 +163,6 @@ def visual_temperature_step(value: Any) -> ConfigType:
)
# Actions
StateTrigger = climate_ns.class_(
"StateTrigger", automation.Trigger.template(Climate.operator("ref"))
)
ControlTrigger = climate_ns.class_(
"ControlTrigger", automation.Trigger.template(ClimateCall.operator("ref"))
)
_CLIMATE_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -250,16 +241,8 @@ _CLIMATE_SCHEMA = (
cv.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.publish_topic
),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ControlTrigger),
}
),
cv.Optional(CONF_ON_STATE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger),
}
),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation(),
cv.Optional(CONF_ON_STATE): automation.validate_automation(),
}
)
)
@@ -288,6 +271,16 @@ def climate_schema(
return _CLIMATE_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", [(Climate.operator("ref"), "x")]
),
automation.CallbackAutomation(
CONF_ON_CONTROL, "add_on_control_callback", [(ClimateCall.operator("ref"), "x")]
),
)
@setup_entity("climate")
async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
visual = config.get(CONF_VISUAL, {})
@@ -443,17 +436,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
)
)
for conf in config.get(CONF_ON_STATE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(Climate.operator("ref"), "x")], conf
)
for conf in config.get(CONF_ON_CONTROL, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(ClimateCall.operator("ref"), "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "climate.h"
namespace esphome::climate {
class ControlTrigger final : public Trigger<ClimateCall &> {
public:
ControlTrigger(Climate *climate) {
climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); });
}
};
class StateTrigger final : public Trigger<Climate &> {
public:
StateTrigger(Climate *climate) {
climate->add_on_state_callback([this](Climate &x) { this->trigger(x); });
}
};
} // namespace esphome::climate
+2
View File
@@ -50,6 +50,7 @@ CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_SCAN_END = "on_scan_end"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_ON_STOP = "on_stop"
CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
@@ -58,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"
+22 -7
View File
@@ -18,6 +18,14 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc
static constexpr int CSE7761_IREF = 52241; // RmsIAC
static constexpr int CSE7761_PREF = 44513; // PowerPAC
// Empirical Sonoff POW CT factor taken from Tasmota
static constexpr uint32_t CURRENT_TRANSFORMER_COEFFICIENT_SCALE = 5;
// PGA 16x on both current channels
static constexpr uint16_t SYSCON_SHUNT = 0xFF04;
// All PGAs 1x, ADC2ON
static constexpr uint16_t SYSCON_CURRENT_TRANSFORMER = 0xFE00;
// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz
static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f;
@@ -68,6 +76,8 @@ void CSE7761Component::dump_config() {
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
ESP_LOGCONFIG(TAG, " Current input: %s",
LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt")));
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Current 1", this->current_sensor_1_);
@@ -93,7 +103,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) {
buffer[1] = reg;
uint32_t len = 2;
if (data) {
if (data < 0xFF) {
if (data <= 0xFF) {
buffer[2] = data & 0xFF;
len = 3;
} else {
@@ -158,16 +168,17 @@ uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) {
}
uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) {
const uint32_t scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1;
uint32_t coeff = 0;
switch (unit) {
case RMS_UC:
coeff = this->data_.coefficient[RMS_UC];
return coeff ? 0x400000 * 100 / coeff : 0;
case RMS_IAC:
coeff = this->data_.coefficient[RMS_IAC];
coeff = this->data_.coefficient[RMS_IAC] * scale;
return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits
case POWER_PAC:
coeff = this->data_.coefficient[POWER_PAC];
coeff = this->data_.coefficient[POWER_PAC] * scale;
return coeff ? 0x80000000 / coeff : 0;
}
return 0;
@@ -195,9 +206,11 @@ bool CSE7761Component::chip_init_() {
if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) {
ESP_LOGW(TAG, "HFConst read failed, energy unavailable");
}
// The pulse counters follow the raw power, so they need the same front end factor as power
const float input_scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1.0f;
for (uint8_t channel = 0; channel < 2; channel++) {
uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const;
this->energy_[channel].wh_per_pulse = static_cast<float>(scale) * CSE7761_ENERGY_WH_SCALE;
this->energy_[channel].wh_per_pulse = static_cast<float>(scale) * CSE7761_ENERGY_WH_SCALE * input_scale;
}
}
@@ -205,7 +218,8 @@ bool CSE7761Component::chip_init_() {
uint8_t sys_status = this->read_(CSE7761_REG_SYSSTATUS, 1);
if (sys_status & 0x10) { // Write enable to protected registers (WREN)
this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04);
this->write_(CSE7761_REG_SYSCON | 0x80,
this->is_current_transformer_() ? SYSCON_CURRENT_TRANSFORMER : SYSCON_SHUNT);
this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183);
// WaveEN|ZxEN, needed for Ufreq
this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1);
@@ -226,14 +240,15 @@ void CSE7761Component::get_data_() {
uint32_t value = this->read_(CSE7761_REG_RMSU, 3);
this->data_.voltage_rms = (value >= 0x800000) ? 0 : value;
// No load threshold of 10mA with a shunt; the raw value stands for 5 times more with a current transformer
value = this->read_(CSE7761_REG_RMSIA, 3);
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value;
value = this->read_(CSE7761_REG_POWERPA, 4);
// PowerPA is two's complement signed 32-bit per datasheet
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast<int32_t>(value);
value = this->read_(CSE7761_REG_RMSIB, 3);
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value;
value = this->read_(CSE7761_REG_POWERPB, 4);
// PowerPB is two's complement signed 32-bit per datasheet
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast<int32_t>(value);
+10
View File
@@ -21,9 +21,15 @@ struct CSE7761EnergyChannel {
float wh_per_pulse{0.0f};
};
enum class CSE7761CurrentInput : uint8_t {
CSE7761_CURRENT_INPUT_SHUNT,
CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER,
};
/// This class implements support for the CSE7761 UART power sensor.
class CSE7761Component final : public PollingComponent, public uart::UARTDevice {
public:
explicit CSE7761Component(CSE7761CurrentInput current_input) : current_input_(current_input) {}
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; }
void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) {
@@ -51,7 +57,11 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice
sensor::Sensor *energy_sensors_[2]{nullptr, nullptr};
CSE7761DataStruct data_;
CSE7761EnergyChannel energy_[2];
const CSE7761CurrentInput current_input_;
bool is_current_transformer_() const {
return this->current_input_ == CSE7761CurrentInput::CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER;
}
void write_(uint8_t reg, uint16_t data);
bool read_once_(uint8_t reg, uint8_t size, uint32_t *value);
bool read_(uint8_t reg, uint8_t size, uint32_t *value);
+11 -1
View File
@@ -36,11 +36,21 @@ CONF_ACTIVE_POWER_2 = "active_power_2"
CONF_ENERGY_1 = "energy_1"
CONF_ENERGY_2 = "energy_2"
CONF_POWER_FACTOR_1 = "power_factor_1"
CONF_CURRENT_INPUT = "current_input"
CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True)
CURRENT_INPUTS = {
"shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT,
"current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER,
}
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CSE7761Component),
cv.Optional(CONF_CURRENT_INPUT, default="shunt"): cv.enum(
CURRENT_INPUTS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
@@ -112,7 +122,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
+4 -3
View File
@@ -202,9 +202,10 @@ void CSE7766Component::parse_data_() {
float pf = NAN;
if (apparent_power > 0) {
pf = power / apparent_power;
if (pf < 0 || pf > 1) {
ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf);
pf = NAN;
if (pf > 1) {
// Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity
ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf);
pf = 1.0f;
}
} else if (apparent_power == 0 && power == 0) {
// No load, report ideal power factor
+4
View File
@@ -6,6 +6,8 @@ from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CONF_SUB_UNIT = "sub_unit"
daikin_brc_ns = cg.esphome_ns.namespace("daikin_brc")
DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR)
@@ -13,6 +15,7 @@ DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).extend(
{
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
cv.Optional(CONF_SUB_UNIT, default=0): cv.int_range(min=0, max=5),
}
)
@@ -20,3 +23,4 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT]))
cg.add(var.set_sub_unit(config[CONF_SUB_UNIT]))
+8 -3
View File
@@ -20,13 +20,18 @@ void DaikinBrcClimate::transmit_state() {
0xDA, 0x17, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x20, 0x00};
// The low 3 bits of the frame header byte address the indoor unit, in the preamble and the state frame
remote_state[3] |= this->sub_unit_;
remote_state[10] |= this->sub_unit_;
remote_state[6] += this->sub_unit_; // preamble checksum covers byte 3
remote_state[12] = this->alt_mode_();
remote_state[13] = this->mode_button_;
remote_state[14] = this->operation_mode_();
remote_state[17] = this->temperature_();
remote_state[18] = this->fan_speed_swing_();
// Calculate checksum
// Calculate state frame checksum
for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1; i++) {
remote_state[DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1] += remote_state[i];
}
@@ -254,8 +259,8 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) {
if (byte != 0x17)
return false;
} else if (pos == 3) {
// frame header
if (byte != 0x18)
// frame header, with this unit's address in the low 3 bits
if (byte != (0x18 | this->sub_unit_))
return false;
} else if (pos == 4) {
// frame type
@@ -60,6 +60,8 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR {
this->fahrenheit_ = value;
this->temperature_step_ = value ? 0.5f : 1.0f;
}
/// Which indoor unit (0 to 5) this climate addresses on a multi-unit system
void set_sub_unit(uint8_t value) { this->sub_unit_ = value; }
protected:
uint8_t mode_button_ = 0x00;
@@ -75,6 +77,7 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR {
bool on_receive(remote_base::RemoteReceiveData data) override;
bool parse_state_frame_(const uint8_t frame[]);
bool fahrenheit_{false};
uint8_t sub_unit_{0};
};
} // namespace esphome::daikin_brc
@@ -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)
+35
View File
@@ -0,0 +1,35 @@
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.types import ConfigType
CODEOWNERS = ["@linkedupbits"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
DOMAIN = "ds3231"
CONF_DS3231_ID = "ds3231_id"
ds3231_ns = cg.esphome_ns.namespace("ds3231")
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x68))
)
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)
automation.register_apply_action(
"ds3231.force_temperature_conversion",
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
automation.ApplyCall("force_temperature_conversion()"),
)
+82
View File
@@ -0,0 +1,82 @@
#include "ds3231.h"
#include "esphome/core/log.h"
// Datasheet:
// - https://www.analog.com/media/en/technical-documentation/data-sheets/DS3231.pdf
namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231");
static constexpr uint8_t DS3231_REG_CONTROL = 0x0E;
static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11;
void DS3231Component::setup() {
if (!this->read_control_status_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery
// INT/SQW is the (unused) interrupt line rather than a square-wave output.
this->control_reg_ |= CONTROL_INTCN;
if (!this->write_control_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
void DS3231Component::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
return;
}
}
bool DS3231Component::read_temperature(float &out) {
uint8_t raw[2];
if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) {
return false;
}
// 10-bit two's complement, 0.25 degC per LSB.
int16_t value = static_cast<int16_t>((raw[0] << 8) | raw[1]) >> 6;
out = value * 0.25f;
return true;
}
void DS3231Component::force_temperature_conversion() {
// Just kick off the conversion - the chip clears CONV and updates the temperature
// register on its own within ~200 ms, and the next temperature poll picks it up.
// Blocking here to wait for it would stall the main loop.
if (this->is_failed() || !this->read_control_status_())
return;
if ((this->status_reg_ & STATUS_BSY) != 0) {
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
return;
}
this->control_reg_ |= CONTROL_CONV;
this->write_control_();
}
bool DS3231Component::read_control_status_() {
uint8_t raw[2];
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
ESP_LOGW(TAG, "Can't read control/status registers.");
return false;
}
this->control_reg_ = raw[0];
this->status_reg_ = raw[1];
return true;
}
bool DS3231Component::write_control_() {
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
ESP_LOGW(TAG, "Can't write control register.");
return false;
}
return true;
}
} // namespace esphome::ds3231
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::ds3231 {
/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the
/// chip through this class.
class DS3231Component final : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
bool read_temperature(float &out);
void force_temperature_conversion();
protected:
static constexpr uint8_t CONTROL_INTCN = 0x04;
static constexpr uint8_t CONTROL_CONV = 0x20;
static constexpr uint8_t CONTROL_EOSC = 0x80;
static constexpr uint8_t STATUS_BSY = 0x04;
bool read_control_status_();
bool write_control_();
uint8_t control_reg_{0};
uint8_t status_reg_{0};
};
} // namespace esphome::ds3231
@@ -0,0 +1,41 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_TEMPERATURE,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
)
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns
DEPENDENCIES = ["ds3231"]
DS3231TemperatureSensor = ds3231_ns.class_(
"DS3231TemperatureSensor",
sensor.Sensor,
cg.PollingComponent,
cg.Parented.template(DS3231Component),
)
CONFIG_SCHEMA = (
sensor.sensor_schema(
DS3231TemperatureSensor,
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=2,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_THERMOMETER,
)
.extend({cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component)})
.extend(cv.polling_component_schema("60s"))
)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_DS3231_ID])
@@ -0,0 +1,23 @@
#include "ds3231_temperature.h"
#include "esphome/core/log.h"
namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231.sensor");
void DS3231TemperatureSensor::update() {
float temperature;
if (!this->parent_->read_temperature(temperature)) {
this->status_set_warning();
return;
}
this->status_clear_warning();
this->publish_state(temperature);
}
void DS3231TemperatureSensor::dump_config() {
LOG_SENSOR("", "DS3231 Temperature", this);
LOG_UPDATE_INTERVAL(this);
}
} // namespace esphome::ds3231
@@ -0,0 +1,17 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/sensor/sensor.h"
#include "../ds3231.h"
namespace esphome::ds3231 {
class DS3231TemperatureSensor final : public sensor::Sensor, public PollingComponent, public Parented<DS3231Component> {
public:
void update() override;
void dump_config() override;
};
} // namespace esphome::ds3231
+1 -1
View File
@@ -87,7 +87,7 @@ async def to_code(config: ConfigType) -> None:
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
cg.add_library("esphome/dsmr_parser", "1.9.0")
cg.add_library("esphome/dsmr_parser", "1.10.0")
def final_validate(config: ConfigType) -> None:
+168
View File
@@ -97,6 +97,12 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
@@ -109,6 +115,12 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_returned_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
@@ -121,6 +133,77 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
# Reactive energy totals and per-quadrant (Q1-Q4) values (Switzerland, e.g. EWL Luzern AM550)
cv.Optional("total_imported_energy_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("total_exported_energy_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q1_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q1_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q2_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q2_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q3_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q3_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q4_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q4_tariff1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("reactive_energy_q4_tariff2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
accuracy_decimals=3,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT_HOURS,
accuracy_decimals=3,
@@ -231,6 +314,91 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
# Per-phase active power and maximum demand (Switzerland, e.g. EWL Luzern AM550)
cv.Optional("power_delivered_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_delivered_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_delivered_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_returned_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_returned_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("power_returned_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_returned_l1_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_returned_l2_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_returned_l3_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_REACTIVE_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("maximum_power_delivered_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("maximum_power_returned_ch"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOWATT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("reactive_power_delivered"): sensor.sensor_schema(
unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE,
accuracy_decimals=3,
+12 -6
View File
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components import socket
from esphome.components.light.effects import register_addressable_effect
from esphome.components.light.types import AddressableLightEffect
import esphome.config_validation as cv
@@ -28,12 +29,17 @@ CHANNELS = {
CONF_UNIVERSE = "universe"
CONF_E131_ID = "e131_id"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(E131Component),
cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True),
}
).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(E131Component),
cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(
*METHODS, upper=True
),
}
).extend(cv.COMPONENT_SCHEMA),
socket.consume_sockets(1, "e131", socket.SocketType.UDP),
)
async def to_code(config: ConfigType) -> None:
+10 -14
View File
@@ -1,6 +1,7 @@
#include "e131.h"
#ifdef USE_NETWORK
#include "e131_addressable_light_effect.h"
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include <algorithm>
@@ -13,18 +14,18 @@ static const int PORT = 5568;
E131Component::E131Component() {}
E131Component::~E131Component() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
if (this->socket_) {
this->socket_->close();
}
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
this->udp_.stop();
#endif
}
void E131Component::setup() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP);
this->socket_ = socket::socket_ip_udp(IPPROTO_IP);
if (!this->socket_) {
ESP_LOGW(TAG, "Could not create socket: errno %d", errno);
this->mark_failed();
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
@@ -54,18 +55,13 @@ void E131Component::setup() {
this->mark_failed();
return;
}
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
if (!this->udp_.begin(PORT)) {
ESP_LOGW(TAG, "Cannot bind E1.31 to port %d.", PORT);
this->mark_failed();
return;
}
#endif
join_igmp_groups_();
}
void E131Component::loop() {
if (this->igmp_join_pending_ && network::is_connected())
this->join_igmp_groups_();
E131Packet packet;
int universe = 0;
uint8_t buf[1460];
@@ -74,7 +70,7 @@ void E131Component::loop() {
// Drain all queued packets so multi-universe frames are applied
// atomically before the light writes. Without this, each universe
// packet would trigger a separate full-strip write causing tearing.
while ((len = this->read_(buf, sizeof(buf))) > 0) {
while ((len = this->socket_->read(buf, sizeof(buf))) > 0) {
if (!this->packet_(buf, (size_t) len, universe, packet)) {
ESP_LOGV(TAG, "Invalid packet received of size %d.", (int) len);
continue;
+4 -21
View File
@@ -1,11 +1,7 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NETWORK
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
#include "esphome/components/socket/socket.h"
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
#include <WiFiUdp.h>
#endif
#include "esphome/core/component.h"
#include <cinttypes>
@@ -45,30 +41,17 @@ class E131Component final : public esphome::Component {
void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; }
protected:
inline ssize_t read_(uint8_t *buf, size_t len) {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
return this->socket_->read(buf, len);
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
if (!this->udp_.parsePacket())
return -1;
return this->udp_.read(buf, len);
#else
return -1;
#endif
}
bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet);
bool process_(int universe, const E131Packet &packet);
bool join_igmp_groups_();
void join_igmp_groups_();
void join_universe_(uint16_t universe);
UniverseConsumer *find_universe_(int universe);
void join_(int universe);
void leave_(int universe);
E131ListenMethod listen_method_{E131_MULTICAST};
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
std::unique_ptr<socket::Socket> socket_;
#elif defined(USE_SOCKET_IMPL_LWIP_TCP)
WiFiUDP udp_;
#endif
bool igmp_join_pending_{false};
std::unique_ptr<socket::UDPSocket> socket_;
std::vector<E131AddressableLightEffect *> light_effects_;
std::vector<UniverseConsumer> universe_consumers_;
};
+33 -24
View File
@@ -3,6 +3,7 @@
#include "e131.h"
#ifdef USE_NETWORK
#include "esphome/components/network/ip_address.h"
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#include "esphome/core/helpers.h"
@@ -59,33 +60,35 @@ union E131RawPacket {
// Get the offset of `property_values[1]`
const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t);
bool E131Component::join_igmp_groups_() {
if (this->listen_method_ != E131_MULTICAST)
return false;
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
if (this->socket_ == nullptr)
return false;
#endif
void E131Component::join_igmp_groups_() {
if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr)
return;
// Joined from loop() once the network is up; RP2040 has no interface to join before then
if (!network::is_connected()) {
this->igmp_join_pending_ = true;
return;
}
this->igmp_join_pending_ = false;
for (auto &entry : this->universe_consumers_) {
if (!entry.consumers)
continue;
if (entry.consumers)
this->join_universe_(entry.universe);
}
}
ip4_addr_t multicast_addr =
network::IPAddress(239, 255, ((entry.universe >> 8) & 0xff), ((entry.universe >> 0) & 0xff));
void E131Component::join_universe_(uint16_t universe) {
ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff));
err_t err;
{
LwIPLock lock;
err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
}
if (err) {
ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", entry.universe);
}
err_t err;
{
LwIPLock lock;
err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
}
return true;
if (err) {
ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe);
} else {
ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe);
}
}
UniverseConsumer *E131Component::find_universe_(int universe) {
@@ -107,9 +110,15 @@ void E131Component::join_(int universe) {
this->universe_consumers_.push_back({static_cast<uint16_t>(universe), 1});
}
if (this->join_igmp_groups_()) {
ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe);
if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr)
return;
// A pending batch join covers this universe too; joining it here as well would break leave_()
if (this->igmp_join_pending_ || !network::is_connected()) {
ESP_LOGD(TAG, "IGMP join for %d universe of E1.31 deferred until the network is up.", universe);
this->igmp_join_pending_ = true;
return;
}
this->join_universe_(universe);
}
void E131Component::leave_(int universe) {
@@ -0,0 +1,75 @@
import esphome.codegen as cg
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, CONF_TIME_ID
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
DOMAIN = "ecocomfort2"
CONF_ECOCOMFORT2_ID = "ecocomfort2_id"
CODEOWNERS = ["@gledian"]
DEPENDENCIES = ["ble_client"]
MULTI_CONF = True
ecocomfort2_ns = cg.esphome_ns.namespace(DOMAIN)
Ecocomfort2Hub = ecocomfort2_ns.class_(
"Ecocomfort2Hub", ble_client.BLEClientNode, cg.PollingComponent
)
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)
.extend(cv.polling_component_schema("30s"))
)
ECOCOMFORT2_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ECOCOMFORT2_ID): cv.use_id(Ecocomfort2Hub),
}
)
_request_child_slot = cg.slot_counter("ECOCOMFORT2_CHILD_COUNT")
async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ECOCOMFORT2_ID])
_request_child_slot(str(parent))
cg.add(parent.register_child(var))
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:
# The unit only talks over an encrypted, bonded link
ble_conf = fv.full_config.get()[esp32_ble.DOMAIN]
if (
ble_conf[CONF_IO_CAPABILITY] != "none"
or ble_conf.get(CONF_AUTH_REQ_MODE) != "sc_bond"
or ble_conf.get(CONF_MAX_KEY_SIZE) != 16
):
raise cv.Invalid(
"ecocomfort2 needs esp32_ble configured with io_capability: none, "
"auth_req_mode: sc_bond and max_key_size: 16"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -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)
@@ -0,0 +1,20 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/core/helpers.h"
namespace esphome::ecocomfort2 {
class Ecocomfort2Hub;
class Ecocomfort2Client : public Parented<Ecocomfort2Hub> {
public:
/// Called after each successful C_OPER readback.
virtual void on_status() = 0;
};
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
@@ -0,0 +1,146 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "ecocomfort2_fan.h"
#include "esphome/core/log.h"
#include <iterator>
namespace esphome::ecocomfort2 {
ESPHOME_LOG_TAG(TAG, "ecocomfort2.fan");
static constexpr int SPEED_COUNT = SPEED_VEL3;
struct Preset {
const char *name;
uint8_t mode;
bool auto_mode;
};
// Order matters: the fan core persists the preset as an index into this list.
static constexpr Preset PRESETS[] = {
{"In", OPER_IN, false},
{"Out", OPER_OUT, false},
{"In-Out", OPER_IN_OUT, false},
{"Sensor", OPER_SENSOR_OR_AUTO, false},
{"Auto", OPER_SENSOR_OR_AUTO, true},
};
static constexpr const Preset &PRESET_IN_OUT = PRESETS[2];
static constexpr const Preset &PRESET_SENSOR = PRESETS[3];
static const Preset *find_preset(const char *name) {
for (const auto &preset : PRESETS) {
if (preset.name == name)
return &preset;
}
return nullptr;
}
static const Preset *find_preset(uint8_t mode, bool auto_mode) {
for (const auto &preset : PRESETS) {
if (preset.mode == mode && preset.auto_mode == auto_mode)
return &preset;
}
return nullptr;
}
void Ecocomfort2Fan::setup() {
this->set_supported_preset_modes(
{PRESETS[0].name, PRESETS[1].name, PRESETS[2].name, PRESETS[3].name, PRESETS[4].name});
// Restore the last preset and speed so turning on resumes them; on/off comes from the device.
auto restore = this->restore_state_();
if (restore.has_value()) {
this->speed = restore->speed;
if (restore->preset_mode < std::size(PRESETS))
this->set_preset_mode_(PRESETS[restore->preset_mode].name);
}
}
void Ecocomfort2Fan::dump_config() { LOG_FAN("", "Ecocomfort2 Fan", this); }
fan::FanTraits Ecocomfort2Fan::get_traits() {
fan::FanTraits traits;
traits.set_speed(true);
traits.set_supported_speed_count(SPEED_COUNT);
this->wire_preset_modes_(traits);
return traits;
}
void Ecocomfort2Fan::control(const fan::FanCall &call) {
if (!this->parent_->is_ready()) {
ESP_LOGW(TAG, "Not ready, cannot handle control call");
return;
}
// Any call that does not turn the fan off turns it on
auto state = call.get_state();
this->state = !state.has_value() || *state;
// The core clears the preset in a speed call; here the preset is the air flow mode, so keep it.
if (call.has_preset_mode())
this->set_preset_mode_(call.get_preset_mode());
const Preset *preset = find_preset(this->get_preset_mode().c_str());
if (preset == nullptr) {
preset = &PRESET_IN_OUT;
this->set_preset_mode_(preset->name);
}
auto speed = call.get_speed();
if (speed.has_value()) {
this->speed = *speed;
// A manual speed leaves automatic speed control
if (preset->auto_mode) {
preset = &PRESET_SENSOR;
this->set_preset_mode_(preset->name);
}
}
this->speed = clamp(this->speed, 1, SPEED_COUNT);
if (!this->state) {
this->parent_->write_oper(OPER_OFF, SPEED_OFF);
} else {
this->parent_->write_oper(preset->mode, preset->auto_mode ? FLAG_AUTO : static_cast<uint8_t>(this->speed));
}
this->publish_state();
}
void Ecocomfort2Fan::on_status() {
uint8_t mode = this->parent_->get_mode();
uint8_t raw_speed = this->parent_->get_speed();
// Off keeps the preset and speed so turning on resumes them
if (mode == OPER_OFF) {
if (!this->state)
return;
this->state = false;
this->publish_state();
return;
}
int new_speed = this->speed;
uint8_t base_speed = raw_speed & SPEED_MASK;
if (raw_speed & FLAG_BOOST) {
new_speed = SPEED_COUNT;
} else if (raw_speed & FLAG_NIGHT) {
new_speed = SPEED_SLEEP;
} else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) {
new_speed = base_speed;
}
const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0);
const char *new_preset = preset != nullptr ? preset->name : nullptr;
bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset;
if (this->state && this->speed == new_speed && same_preset)
return;
this->state = true;
this->speed = new_speed;
if (new_preset != nullptr) {
this->set_preset_mode_(new_preset);
} else {
this->clear_preset_mode_();
}
this->publish_state();
}
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/core/component.h"
#include "esphome/components/fan/fan.h"
#include "ecocomfort2_child.h"
#include "ecocomfort2_hub.h"
namespace esphome::ecocomfort2 {
class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Component {
public:
void setup() override;
void dump_config() override;
fan::FanTraits get_traits() override;
void on_status() override;
protected:
void control(const fan::FanCall &call) override;
};
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
@@ -0,0 +1,323 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "ecocomfort2_hub.h"
#include "esphome/core/log.h"
#include <cstdio>
namespace esphome::ecocomfort2 {
ESPHOME_LOG_TAG(TAG, "ecocomfort2");
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;
}
// 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] Read request for handle 0x%x failed, status=%d", this->parent_->address_str(), handle, status);
}
}
void Ecocomfort2Hub::dump_config() {
ESP_LOGCONFIG(TAG,
"Ecocomfort2 Hub:\n"
" 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,
esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_DISCONNECT_EVT: {
ESP_LOGD(TAG, "[%s] Disconnected: reason=%d", this->parent_->address_str(), param->disconnect.reason);
this->cancel_timeout(AUTH_TIMEOUT_ID);
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: {
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_ = 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();
// A bonded device may already have re-encrypted the link before discovery finished.
if (this->parent_->is_paired()) {
this->on_paired_();
break;
}
ESP_LOGI(TAG, "[%s] Requesting BLE encryption", this->parent_->address_str());
if (this->parent_->pair() != ESP_OK) {
ESP_LOGW(TAG, "[%s] BLE pair request failed, disconnecting", this->parent_->address_str());
this->parent_->disconnect();
break;
}
this->set_timeout(AUTH_TIMEOUT_ID, AUTH_TIMEOUT_MS, [this]() {
ESP_LOGW(TAG, "[%s] BLE authentication timed out, disconnecting", this->parent_->address_str());
this->parent_->disconnect();
});
break;
}
case ESP_GATTC_READ_CHAR_EVT: {
if (param->read.conn_id != this->parent_->get_conn_id()) {
break;
}
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;
}
break;
}
default:
break;
}
}
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) {
return;
}
if (!param->ble_security.auth_cmpl.success) {
this->cancel_timeout(AUTH_TIMEOUT_ID);
ESP_LOGW(TAG, "[%s] BLE authentication failed, reason=%d, disconnecting", this->parent_->address_str(),
param->ble_security.auth_cmpl.fail_reason);
this->parent_->disconnect();
return;
}
this->on_paired_();
}
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());
return;
}
uint8_t data[2]{mode, speed};
ESP_LOGD(TAG, "[%s] Writing C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), mode, speed);
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_oper_,
sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status != ESP_GATT_OK) {
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->read_char_(this->char_handle_oper_); });
}
#ifdef ECOCOMFORT2_CHILD_COUNT
void Ecocomfort2Hub::register_child(Ecocomfort2Client *obj) {
this->children_.push_back(obj);
obj->set_parent(this);
}
#endif
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
@@ -0,0 +1,136 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/core/component.h"
#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;
// C_OPER byte 0: operating mode
static constexpr uint8_t OPER_OFF = 0x00;
static constexpr uint8_t OPER_IN = 0x01;
static constexpr uint8_t OPER_OUT = 0x02;
static constexpr uint8_t OPER_IN_OUT = 0x03;
static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04;
// C_OPER byte 1: speed in the low nibble, flags in the high nibble
static constexpr uint8_t SPEED_OFF = 0x00;
static constexpr uint8_t SPEED_SLEEP = 0x01;
static constexpr uint8_t SPEED_VEL1 = 0x02;
static constexpr uint8_t SPEED_VEL2 = 0x03;
static constexpr uint8_t SPEED_VEL3 = 0x04;
static constexpr uint8_t SPEED_MASK = 0x0F;
static constexpr uint8_t FLAG_AUTO = 0x10;
static constexpr uint8_t FLAG_BOOST = 0x40;
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; }
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
#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(); }
/// Write C_OPER (mode byte, speed/flags byte) and read it back shortly after.
void write_oper(uint8_t mode, uint8_t speed);
/// Raw C_OPER values from the last readback.
uint8_t get_mode() const { return this->mode_; }
uint8_t get_speed() const { return this->speed_; }
protected:
static constexpr uint32_t AUTH_TIMEOUT_ID = 0;
static constexpr uint32_t READBACK_TIMEOUT_ID = 1;
static constexpr uint32_t AUTH_TIMEOUT_MS = 10000;
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};
};
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
+23
View File
@@ -0,0 +1,23 @@
import esphome.codegen as cg
from esphome.components import fan
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import ECOCOMFORT2_CLIENT_SCHEMA, ecocomfort2_ns, register_ecocomfort2_child
CODEOWNERS = ["@gledian"]
DEPENDENCIES = ["ecocomfort2"]
Ecocomfort2Fan = ecocomfort2_ns.class_("Ecocomfort2Fan", fan.Fan, cg.Component)
CONFIG_SCHEMA = (
fan.fan_schema(Ecocomfort2Fan)
.extend(cv.COMPONENT_SCHEMA)
.extend(ECOCOMFORT2_CLIENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await fan.new_fan(config)
await cg.register_component(var, config)
await register_ecocomfort2_child(var, config)
+62
View File
@@ -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)
+5
View File
@@ -27,6 +27,7 @@ EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice)
CONF_EMONTX_ID = "emontx_id"
CONF_TAG_NAME = "tag_name"
CONF_ON_JSON = "on_json"
CONF_LOCK_ON_REBOOT = "lock_on_reboot"
DOMAIN = "emontx"
@@ -58,6 +59,7 @@ 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,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -133,6 +135,9 @@ 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))
# Action: emontx.send_command
+22 -1
View File
@@ -6,7 +6,27 @@ namespace esphome::emontx {
ESPHOME_LOG_TAG(TAG, "emontx");
void EmonTx::setup() { this->buffer_pos_ = 0; }
void EmonTx::setup() {
this->buffer_pos_ = 0;
if (!this->lock_on_reboot_)
return;
// The line feed ends any boot noise first
ESP_LOGI(TAG, "Unlocking emonTx after boot");
this->write_byte('\n');
this->send_command("emonunlock");
}
void EmonTx::on_shutdown() {
if (!this->lock_on_reboot_)
return;
if (this->is_paused()) {
ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused");
return;
}
ESP_LOGI(TAG, "Locking emonTx before shutdown");
this->send_command("emonlock");
this->flush();
}
/**
* @brief Implements the main loop for parsing data from the serial port.
@@ -97,6 +117,7 @@ void EmonTx::parse_json_(const char *data, size_t len) {
*/
void EmonTx::dump_config() {
ESP_LOGCONFIG(TAG, "EmonTx:");
ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_));
#ifdef USE_SENSOR
ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size());
+6
View File
@@ -33,6 +33,7 @@ class EmonTx final : public Component, public uart::UARTDevice {
void loop() override;
void setup() override;
void dump_config() override;
void on_shutdown() override;
template<typename F> void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward<F>(callback)); }
@@ -48,6 +49,10 @@ 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; }
#ifdef USE_SENSOR
void init_sensors(size_t count) { this->sensors_.init(count); }
void register_sensor(const char *tag_name, sensor::Sensor *sensor);
@@ -64,6 +69,7 @@ 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};
bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line
std::array<char, MAX_LINE_LENGTH + 1> buffer_{};
};
@@ -118,3 +118,39 @@ jd79660.extend(
(0x04,),
),
)
# Good Display GDEY0213F51
#
# Panel: 2.13" BWYR (4-color) e-paper, 122x250, controller JD79661
# Init derived from Good Display sample
# <https://www.good-display.com/companyfile/1047.html>
# Cross-checked against GxEPD2_213c_GDEY0213F51
# <https://github.com/ZinggJM/GxEPD2/blob/master/src/epd4c/GxEPD2_213c_GDEY0213F51.cpp>
#
# Note: busy pin uses LOW=busy, HIGH=idle.
#
# fmt: off
jd79660.extend(
"goodisplay-gdey0213f51-2.13",
width=128,
height=250,
initsequence=(
(0x4D, 0x78,),
(0x00, 0x0F, 0x29,), # PSR
(0x01, 0x07, 0x00,), # PWRR
(0x03, 0x10, 0x54, 0x44,), # POFS
(0x06, 0x05, 0x00, 0x3F, 0x0A, 0x25, 0x12, 0x1A,), # BTST_P
(0x50, 0x37,), # CDI
(0x60, 0x02, 0x02,), # TCON
(0x61, 128 // 256, 128 % 256, 250 // 256, 250 % 256,), # TRES: 128x250 fixed
(0xE7, 0x1C,),
(0xE3, 0x22,),
(0xB4, 0xD0,),
(0xB5, 0x03,),
(0xE9, 0x01,),
(0x30, 0x08,),
# Power On (0x04): Must be early part of init seq = Disabled later!
(0x04,),
),
)
+2
View File
@@ -1 +1,3 @@
"""ES7210 audio ADC component."""
DOMAIN = "es7210"
+33 -5
View File
@@ -1,13 +1,18 @@
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import esp32, i2c
from esphome.components.audio_adc import AudioAdc
from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["i2c"]
CONF_CHANNEL_GAINS = "channel_gains"
CONF_TDM = "tdm"
es7210_ns = cg.esphome_ns.namespace("es7210")
ES7210 = es7210_ns.class_("ES7210", AudioAdc, cg.Component, i2c.I2CDevice)
@@ -21,24 +26,42 @@ ES7210_BITS_PER_SAMPLE_ENUM = {
ES7210_MIC_GAINS = [0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 34.5, 36, 37.5]
ES7210_MIC_GAIN_DEFAULT = 24
_validate_bits = cv.float_with_unit("bits", "bit")
CONFIG_SCHEMA = (
def _validate_tdm(value: bool) -> bool:
"""Reject TDM on ESP32 variants without TDM hardware support."""
if value and CORE.is_esp32:
esp32.only_on_variant(
unsupported=[VARIANT_ESP32, VARIANT_ESP32S2],
msg_prefix="ES7210 TDM mode",
)(value)
return value
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ES7210),
cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All(
_validate_bits, cv.enum(ES7210_BITS_PER_SAMPLE_ENUM)
),
cv.Optional(CONF_MIC_GAIN, default="24db"): cv.All(
cv.Optional(CONF_MIC_GAIN): cv.All(
cv.decibel, cv.one_of(*ES7210_MIC_GAINS)
),
cv.Optional(CONF_CHANNEL_GAINS): cv.All(
cv.ensure_list(cv.All(cv.decibel, cv.one_of(*ES7210_MIC_GAINS))),
cv.Length(min=4, max=4),
),
cv.Optional(CONF_SAMPLE_RATE, default=16000): cv.int_range(min=1),
cv.Optional(CONF_TDM, default=False): cv.All(cv.boolean, _validate_tdm),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x40))
.extend(i2c.i2c_device_schema(0x40)),
cv.has_at_most_one_key(CONF_MIC_GAIN, CONF_CHANNEL_GAINS),
)
@@ -48,5 +71,10 @@ async def to_code(config: ConfigType) -> None:
await i2c.register_i2c_device(var, config)
cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
cg.add(var.set_mic_gain(config[CONF_MIC_GAIN]))
if channel_gains := config.get(CONF_CHANNEL_GAINS):
for channel, gain in enumerate(channel_gains):
cg.add(var.set_channel_gain(channel, gain))
else:
cg.add(var.set_mic_gain(config.get(CONF_MIC_GAIN, ES7210_MIC_GAIN_DEFAULT)))
cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE]))
cg.add(var.set_tdm(config[CONF_TDM]))
+23 -8
View File
@@ -27,8 +27,10 @@ void ES7210::dump_config() {
ESP_LOGCONFIG(TAG,
"ES7210 audio ADC:\n"
" Bits Per Sample: %" PRIu8 "\n"
" Sample Rate: %" PRIu32,
this->bits_per_sample_, this->sample_rate_);
" Sample Rate: %" PRIu32 "\n"
" Channel Gains: %.1f dB, %.1f dB, %.1f dB, %.1f dB",
this->bits_per_sample_, this->sample_rate_, this->channel_gains_[0], this->channel_gains_[1],
this->channel_gains_[2], this->channel_gains_[3]);
if (this->is_failed()) {
ESP_LOGE(TAG, " Failed to initialize");
@@ -88,7 +90,17 @@ void ES7210::setup() {
}
bool ES7210::set_mic_gain(float mic_gain) {
this->mic_gain_ = clamp<float>(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX);
this->channel_gains_.fill(clamp<float>(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX));
if (this->setup_complete_) {
return this->configure_mic_gain_();
}
return true;
}
bool ES7210::set_channel_gain(uint8_t channel, float gain) {
if (channel >= this->channel_gains_.size())
return false;
this->channel_gains_[channel] = clamp<float>(gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX);
if (this->setup_complete_) {
return this->configure_mic_gain_();
}
@@ -133,7 +145,6 @@ bool ES7210::configure_sample_rate_() {
}
bool ES7210::configure_mic_gain_() {
auto regv = this->es7210_gain_reg_value_(this->mic_gain_);
for (uint8_t i = 0; i < 4; ++i) {
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43 + i, 0x10, 0x00));
}
@@ -144,26 +155,30 @@ bool ES7210::configure_mic_gain_() {
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00));
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x10, 0x10));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, regv));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f,
this->es7210_gain_reg_value_(this->channel_gains_[0])));
// Configure mic 2
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00));
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x10, 0x10));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f,
this->es7210_gain_reg_value_(this->channel_gains_[1])));
// Configure mic 3
// MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains.
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00));
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f,
this->es7210_gain_reg_value_(this->channel_gains_[2])));
// Configure mic 4
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00));
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f,
this->es7210_gain_reg_value_(this->channel_gains_[3])));
return true;
}
+10 -3
View File
@@ -1,5 +1,7 @@
#pragma once
#include <array>
#include "esphome/components/audio_adc/audio_adc.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
@@ -27,10 +29,15 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I
void dump_config() override;
void set_bits_per_sample(ES7210BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; }
/// Sets all four channel gains, replacing any configured per-channel gains.
bool set_mic_gain(float mic_gain) override;
/// Sets a zero-based channel gain; returns false for an invalid channel or an I2C failure.
bool set_channel_gain(uint8_t channel, float gain);
void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; }
void set_tdm(bool enable_tdm) { this->enable_tdm_ = enable_tdm; }
float mic_gain() override { return this->mic_gain_; };
/// Returns the MIC1 gain; other channels may have different gains.
float mic_gain() override { return this->channel_gains_[0]; };
protected:
/// @brief Updates an I2C registry address by modifying the current state
@@ -50,8 +57,8 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I
bool configure_sample_rate_();
bool setup_complete_{false};
bool enable_tdm_{false}; // TDM is unsupported in ESPHome as of version 2024.12
float mic_gain_{0};
bool enable_tdm_{false};
std::array<float, 4> channel_gains_{{0.0f, 0.0f, 0.0f, 0.0f}};
ES7210BitsPerSample bits_per_sample_{ES7210_BITS_PER_SAMPLE_16};
uint32_t sample_rate_{0};
};
+4
View File
@@ -164,6 +164,10 @@ void ESP32InternalGPIOPin::setup() {
conf.pull_down_en = this->flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE;
conf.intr_type = GPIO_INTR_DISABLE;
gpio_config(&conf);
#ifdef CONFIG_FREERTOS_USE_TICKLESS_IDLE
// If auto light sleep is used gpio sleep mode needs to be disabled for the pin
gpio_sleep_sel_dis(this->get_pin_num());
#endif
if (this->flags_ & gpio::FLAG_OUTPUT) {
gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength());
}
+2 -13
View File
@@ -350,19 +350,8 @@ bool ESP32BLE::ble_setup_() {
}
} else {
const auto &app_name = App.get_name();
size_t name_len = app_name.length();
if (name_len > 20) {
if (App.is_name_add_mac_suffix_enabled()) {
// Keep first 13 chars and last 7 chars (MAC suffix), remove middle
memcpy(name_buffer, app_name.c_str(), 13);
memcpy(name_buffer + 13, app_name.c_str() + name_len - 7, 7);
} else {
memcpy(name_buffer, app_name.c_str(), 20);
}
name_buffer[20] = '\0';
} else {
memcpy(name_buffer, app_name.c_str(), name_len + 1); // Include null terminator
}
truncate_name_to(name_buffer, sizeof(name_buffer), app_name.c_str(), app_name.length(),
App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0);
device_name = name_buffer;
}

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