diff --git a/AGENTS.md b/AGENTS.md index 6bdd9467c0..5031c4a457 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`; 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 `; 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..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: diff --git a/CODEOWNERS b/CODEOWNERS index 4264479349..075b8113d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/__main__.py b/esphome/__main__.py index 77c1c9f1ad..2b094e67a1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -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() diff --git a/esphome/automation.py b/esphome/automation.py index 2f90cbfcbb..4a2c10cb52 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -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) diff --git a/esphome/bundle.py b/esphome/bundle.py index b633c5ca4f..f499e0de1b 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -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 `` 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 diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 46b7e7a2ff..f813087645 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -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 { diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 8cdea4f01a..b44d88d56e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -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() diff --git a/esphome/components/apc1/__init__.py b/esphome/components/apc1/__init__.py new file mode 100644 index 0000000000..355ecc2305 --- /dev/null +++ b/esphome/components/apc1/__init__.py @@ -0,0 +1,3 @@ +"""Support for ScioSense APC1 air quality sensor.""" + +DOMAIN = "apc1" diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp new file mode 100644 index 0000000000..beca9d8581 --- /dev/null +++ b/esphome/components/apc1/apc1.cpp @@ -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 +#include + +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(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(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(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 buf{ + APC1_HEADER_1, + APC1_HEADER_2, + static_cast(cmd), + static_cast((data >> 8) & 0xFF), + static_cast(data & 0xFF), + 0, + 0, + }; + uint16_t checksum = apc1_checksum(buf.data(), 5); + buf[5] = static_cast((checksum >> 8) & 0xFF); + buf[6] = static_cast(checksum & 0xFF); + this->write_array(buf); +} + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/apc1.h b/esphome/components/apc1/apc1.h new file mode 100644 index 0000000000..850738ead6 --- /dev/null +++ b/esphome/components/apc1/apc1.h @@ -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 + +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 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 diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py new file mode 100644 index 0000000000..b64a49903f --- /dev/null +++ b/esphome/components/apc1/sensor.py @@ -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) + ) diff --git a/esphome/components/apds9930/__init__.py b/esphome/components/apds9930/__init__.py new file mode 100644 index 0000000000..0e6330bd88 --- /dev/null +++ b/esphome/components/apds9930/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@iyesin"] +DOMAIN = "apds9930" diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp new file mode 100644 index 0000000000..6debc0d9c7 --- /dev/null +++ b/esphome/components/apds9930/apds9930.cpp @@ -0,0 +1,140 @@ +#include "apds9930.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +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 diff --git a/esphome/components/apds9930/apds9930.h b/esphome/components/apds9930/apds9930.h new file mode 100644 index 0000000000..cc7f77d3d0 --- /dev/null +++ b/esphome/components/apds9930/apds9930.h @@ -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 diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py new file mode 100644 index 0000000000..2b169271c6 --- /dev/null +++ b/esphome/components/apds9930/sensor.py @@ -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) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7dad4e390e..f63039a971 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -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 diff --git a/esphome/components/aranet/__init__.py b/esphome/components/aranet/__init__.py new file mode 100644 index 0000000000..d1d75568e9 --- /dev/null +++ b/esphome/components/aranet/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@patrickod"] +DOMAIN = "aranet" diff --git a/esphome/components/aranet/aranet.cpp b/esphome/components/aranet/aranet.cpp new file mode 100644 index 0000000000..4fa28eec17 --- /dev/null +++ b/esphome/components/aranet/aranet.cpp @@ -0,0 +1,157 @@ +#include "aranet.h" + +#include + +#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(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(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 diff --git a/esphome/components/aranet/aranet.h b/esphome/components/aranet/aranet.h new file mode 100644 index 0000000000..7064ad7d45 --- /dev/null +++ b/esphome/components/aranet/aranet.h @@ -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 diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py new file mode 100644 index 0000000000..f22d242f9c --- /dev/null +++ b/esphome/components/aranet/sensor.py @@ -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) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1480faa8c3..332638e75b 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -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", diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py new file mode 100644 index 0000000000..bd63fd4ef2 --- /dev/null +++ b/esphome/components/bh1745/__init__.py @@ -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])) diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp new file mode 100644 index 0000000000..24744c3330 --- /dev/null +++ b/esphome/components/bh1745/bh1745.cpp @@ -0,0 +1,182 @@ +#include "bh1745.h" +#include +#include +#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(time); +} + +static uint8_t get_adc_gain(AdcGain gain) { + return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast(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(static_cast(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(this->measurement_time_)) || + !this->write_byte(REG_MODE_CONTROL2, static_cast(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(clear) * 25 < static_cast(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 diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h new file mode 100644 index 0000000000..6463aab38d --- /dev/null +++ b/esphome/components/bh1745/bh1745.h @@ -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 diff --git a/esphome/components/bh1745/output/__init__.py b/esphome/components/bh1745/output/__init__.py new file mode 100644 index 0000000000..6c5da0396b --- /dev/null +++ b/esphome/components/bh1745/output/__init__.py @@ -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()) diff --git a/esphome/components/bh1745/output/bh1745_output.h b/esphome/components/bh1745/output/bh1745_output.h new file mode 100644 index 0000000000..c6f30304bd --- /dev/null +++ b/esphome/components/bh1745/output/bh1745_output.h @@ -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 { + protected: + void write_state(bool state) override { this->parent_->set_interrupt_state(state); } +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/sensor.py b/esphome/components/bh1745/sensor.py new file mode 100644 index 0000000000..6c90446625 --- /dev/null +++ b/esphome/components/bh1745/sensor.py @@ -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) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 109a22c7bc..1384f8b008 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -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) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index d4b4906c30..46f7d9488c 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -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); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 49457200bc..7426cc5614 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #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 && + 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 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 class MultiClickTrigger final : public MultiClickTriggerBase { - public: - MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : MultiClickTriggerBase(parent) { - init_array_from(this->timing_storage_, timing); - this->timing_ = this->timing_storage_.data(); - this->timing_count_ = N; - } - - protected: - std::array timing_storage_{}; -}; - } // namespace esphome::binary_sensor diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 0e2da59ab1..e8dc09abba 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -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; } diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index 333bca3715..257d9459b5 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -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); diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 9f6bd7814e..248002d185 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -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) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index a1709c1fa5..37f718fb7e 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -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; } diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 56d6a60952..a1fefdffe8 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -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 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 void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(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 void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward(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 temperature_callback_{}; +#ifdef USE_BMI270_BMM150 + LazyCallbackManager 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 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h new file mode 100644 index 0000000000..747a0898fa --- /dev/null +++ b/esphome/components/bmi270/bmm150_aux.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include +#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(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]); + trim.y1 = static_cast(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]); + trim.z4 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z4)); + trim.x2 = static_cast(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]); + trim.y2 = static_cast(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]); + trim.z2 = static_cast(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(bmm150_trim_word(regs, BMM150_TRIM_Z3)); + trim.xy2 = static_cast(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 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index ad36e592d1..97127fd2dd 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -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])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py index 0e1b0604a3..242b86614d 100644 --- a/esphome/components/bmi270/sensor.py +++ b/esphome/components/bmi270/sensor.py @@ -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)) diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py new file mode 100644 index 0000000000..03a100d929 --- /dev/null +++ b/esphome/components/bq25186/__init__.py @@ -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 + ) diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py new file mode 100644 index 0000000000..fb741950e2 --- /dev/null +++ b/esphome/components/bq25186/binary_sensor.py @@ -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 + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp new file mode 100644 index 0000000000..edea50d111 --- /dev/null +++ b/esphome/components/bq25186/bq25186.cpp @@ -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, ®_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 ®s) const { + for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) { + const size_t index = reg - BQ25186_REG_VBAT_CTRL; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]); + if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0) + return false; + } + return true; +} + +bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { + // Setup rejects an unknown chip; the action must not program it before that check or after it failed + if (!this->is_ready() || this->is_failed()) { + ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); + return false; + } + this->regs_ = regs; + this->configured_ = true; + for (size_t i = 0; i < regs.size(); i++) { + const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); + const 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 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h new file mode 100644 index 0000000000..0a9a45c28b --- /dev/null +++ b/esphome/components/bq25186/bq25186.h @@ -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 + +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; +using BQ25186StatusRegisters = std::array; + +class BQ25186BinarySensor; +class BQ25186TextSensor; + +class BQ25186Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_setup_registers(const BQ25186ConfigRegisters ®s) { + this->regs_ = regs; + this->configure_on_boot_ = true; + } + bool apply_config(const BQ25186ConfigRegisters ®s); +#ifdef BQ25186_BINARY_SENSOR_COUNT + void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#endif +#ifdef USE_BQ25186_PG_GPO + /// The /PG/GPO pin is put in general purpose output mode when a switch drives it + void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; } + bool write_pg_gpo_level(bool level) { + return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL, + level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0); + } +#endif + /// Write the masked bits of SHIP_RST + bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); } + + protected: + bool write_register_(uint8_t reg, uint8_t value); + uint8_t expected_register_(size_t index) const; + bool config_matches_(const BQ25186StatusRegisters ®s) const; +#ifdef USE_BQ25186_PG_GPO + void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); +#endif + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + +#ifdef BQ25186_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + StaticVector 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 diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h new file mode 100644 index 0000000000..265f34a445 --- /dev/null +++ b/esphome/components/bq25186/bq25186_binary_sensor.h @@ -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 diff --git a/esphome/components/bq25186/bq25186_button.h b/esphome/components/bq25186/bq25186_button.h new file mode 100644 index 0000000000..e088166ac6 --- /dev/null +++ b/esphome/components/bq25186/bq25186_button.h @@ -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 { + 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 diff --git a/esphome/components/bq25186/bq25186_switch.h b/esphome/components/bq25186/bq25186_switch.h new file mode 100644 index 0000000000..9abe6fec51 --- /dev/null +++ b/esphome/components/bq25186/bq25186_switch.h @@ -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 { + 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 diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp new file mode 100644 index 0000000000..660313e641 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.cpp @@ -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(name), sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h new file mode 100644 index 0000000000..33c8f806fa --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.h @@ -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 diff --git a/esphome/components/bq25186/button.py b/esphome/components/bq25186/button.py new file mode 100644 index 0000000000..25527ff9a2 --- /dev/null +++ b/esphome/components/bq25186/button.py @@ -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]) diff --git a/esphome/components/bq25186/switch.py b/esphome/components/bq25186/switch.py new file mode 100644 index 0000000000..e5eb2d98a2 --- /dev/null +++ b/esphome/components/bq25186/switch.py @@ -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) diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py new file mode 100644 index 0000000000..b27d2fbcfa --- /dev/null +++ b/esphome/components/bq25186/text_sensor.py @@ -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) diff --git a/esphome/components/bq27220/__init__.py b/esphome/components/bq27220/__init__.py new file mode 100644 index 0000000000..2a5d7bc1d7 --- /dev/null +++ b/esphome/components/bq27220/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@rggammon"] +DOMAIN = "bq27220" diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp new file mode 100644 index 0000000000..d2f7961e02 --- /dev/null +++ b/esphome/components/bq27220/bq27220.cpp @@ -0,0 +1,93 @@ +#include "bq27220.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +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(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 diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h new file mode 100644 index 0000000000..d88b76a20f --- /dev/null +++ b/esphome/components/bq27220/bq27220.h @@ -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 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 diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py new file mode 100644 index 0000000000..a3fcec2243 --- /dev/null +++ b/esphome/components/bq27220/sensor.py @@ -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) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 3bdea03bf5..8187dac008 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -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() diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 36e9ec406d..0aa48bc3ea 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -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(); - this->dns_server_->start(ip); -#elif defined(USE_ARDUINO) - this->dns_server_ = make_unique(); - 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; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index b47af9d978..013ba25b5f 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -1,16 +1,11 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_CAPTIVE_PORTAL -#include -#if defined(USE_ESP32) -#include "dns_server_esp32_idf.h" -#elif defined(USE_ARDUINO) -#include -#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 dns_server_{nullptr}; -#endif + web_server_base::CaptiveDNS dns_; }; extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 17aeb1467d..a59c03ed1e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -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) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h deleted file mode 100644 index d6aba02f92..0000000000 --- a/esphome/components/climate/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "climate.h" - -namespace esphome::climate { - -class ControlTrigger final : public Trigger { - public: - ControlTrigger(Climate *climate) { - climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); - } -}; - -class StateTrigger final : public Trigger { - public: - StateTrigger(Climate *climate) { - climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::climate diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index c44a323f65..49f55ab47f 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -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" diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index a610181222..4ba93af01f 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -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(scale) * CSE7761_ENERGY_WH_SCALE; + this->energy_[channel].wh_per_pulse = static_cast(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(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(value); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 557de0e342..fc4eac6477 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -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); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 8ffbb44771..6f695307de 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -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) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 0bf077b746..bbe76855fb 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -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 diff --git a/esphome/components/daikin_brc/climate.py b/esphome/components/daikin_brc/climate.py index c5c1d3739e..c710dc94a8 100644 --- a/esphome/components/daikin_brc/climate.py +++ b/esphome/components/daikin_brc/climate.py @@ -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])) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index cfb3521a66..c16eea733f 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -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 diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index c963f72ab6..5160e44a11 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -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 diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index aca0901d2b..576887f6dc 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -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) diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py new file mode 100644 index 0000000000..262c951e80 --- /dev/null +++ b/esphome/components/ds3231/__init__.py @@ -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()"), +) diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp new file mode 100644 index 0000000000..4f7396fcbd --- /dev/null +++ b/esphome/components/ds3231/ds3231.cpp @@ -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((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 diff --git a/esphome/components/ds3231/ds3231.h b/esphome/components/ds3231/ds3231.h new file mode 100644 index 0000000000..b8a81a2e49 --- /dev/null +++ b/esphome/components/ds3231/ds3231.h @@ -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 diff --git a/esphome/components/ds3231/sensor/__init__.py b/esphome/components/ds3231/sensor/__init__.py new file mode 100644 index 0000000000..a92fc1f48c --- /dev/null +++ b/esphome/components/ds3231/sensor/__init__.py @@ -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]) diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp new file mode 100644 index 0000000000..8d7e1156be --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -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 diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.h b/esphome/components/ds3231/sensor/ds3231_temperature.h new file mode 100644 index 0000000000..aa31c42438 --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.h @@ -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 { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::ds3231 diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 07dbf55831..4ba9d1696a 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -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: diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 6aecc62d6b..c8d12c326b 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -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, diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index dbe9b1e45c..2d48f5a593 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -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: diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index 09c854c649..1d847963fe 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -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 @@ -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; diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index b0a8b4f83f..7d6983ddc3 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -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 -#endif #include "esphome/core/component.h" #include @@ -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_; -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - WiFiUDP udp_; -#endif + bool igmp_join_pending_{false}; + std::unique_ptr socket_; std::vector light_effects_; std::vector universe_consumers_; }; diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index e3f2e3940a..14e3b64133 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -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(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) { diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py new file mode 100644 index 0000000000..8a0e165418 --- /dev/null +++ b/esphome/components/ecocomfort2/__init__.py @@ -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 diff --git a/esphome/components/ecocomfort2/binary_sensor.py b/esphome/components/ecocomfort2/binary_sensor.py new file mode 100644 index 0000000000..15e5b69d2d --- /dev/null +++ b/esphome/components/ecocomfort2/binary_sensor.py @@ -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) diff --git a/esphome/components/ecocomfort2/ecocomfort2_child.h b/esphome/components/ecocomfort2/ecocomfort2_child.h new file mode 100644 index 0000000000..b6d2fc87cb --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_child.h @@ -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 { + public: + /// Called after each successful C_OPER readback. + virtual void on_status() = 0; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp new file mode 100644 index 0000000000..c9d64892c4 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -0,0 +1,146 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_fan.h" +#include "esphome/core/log.h" + +#include + +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(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 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h new file mode 100644 index 0000000000..81af55699f --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -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 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp new file mode 100644 index 0000000000..00ce0f2080 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -0,0 +1,323 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_hub.h" +#include "esphome/core/log.h" + +#include + +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(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(now.year >> 8), + static_cast(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 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h new file mode 100644 index 0000000000..ed046fe049 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -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 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 diff --git a/esphome/components/ecocomfort2/fan.py b/esphome/components/ecocomfort2/fan.py new file mode 100644 index 0000000000..e3dd8e0938 --- /dev/null +++ b/esphome/components/ecocomfort2/fan.py @@ -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) diff --git a/esphome/components/ecocomfort2/sensor.py b/esphome/components/ecocomfort2/sensor.py new file mode 100644 index 0000000000..878cc05f87 --- /dev/null +++ b/esphome/components/ecocomfort2/sensor.py @@ -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) diff --git a/esphome/components/ecocomfort2/text_sensor.py b/esphome/components/ecocomfort2/text_sensor.py new file mode 100644 index 0000000000..4f2ac36539 --- /dev/null +++ b/esphome/components/ecocomfort2/text_sensor.py @@ -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) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 2146545db5..d6c46b5509 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -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 diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index a307ac0de2..14dc89e56a 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -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()); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 83ee4fdbab..bcec7ce171 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -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 void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward(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 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 buffer_{}; }; diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index ca824dccd4..a70d3fe522 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -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 +# +# Cross-checked against GxEPD2_213c_GDEY0213F51 +# +# +# 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,), + ), +) diff --git a/esphome/components/es7210/__init__.py b/esphome/components/es7210/__init__.py index 0038d29040..93bb826919 100644 --- a/esphome/components/es7210/__init__.py +++ b/esphome/components/es7210/__init__.py @@ -1 +1,3 @@ +"""ES7210 audio ADC component.""" + DOMAIN = "es7210" diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index 2defdb0c35..c2dd40b3e8 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -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])) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 0354f719ef..fbabf7460f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -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(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); + this->channel_gains_.fill(clamp(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(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; } diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 42c667b658..7278d5b3c4 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -1,5 +1,7 @@ #pragma once +#include + #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 channel_gains_{{0.0f, 0.0f, 0.0f, 0.0f}}; ES7210BitsPerSample bits_per_sample_{ES7210_BITS_PER_SAMPLE_16}; uint32_t sample_rate_{0}; }; diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 59788e76dc..d9e303c973 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -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()); } diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index b5051c63e1..11290a95f4 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -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; } diff --git a/esphome/components/esp32_power_management/__init__.py b/esphome/components/esp32_power_management/__init__.py new file mode 100644 index 0000000000..76622032cd --- /dev/null +++ b/esphome/components/esp32_power_management/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_power_management" diff --git a/esphome/components/esp32_power_management/esp32_power_management.cpp b/esphome/components/esp32_power_management/esp32_power_management.cpp new file mode 100644 index 0000000000..41ba352e5a --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.cpp @@ -0,0 +1,75 @@ +#include "esp32_power_management.h" +#ifdef USE_ESP32 +#include "esphome/core/log.h" + +#include "esp_private/esp_clk.h" +#include "soc/rtc.h" + +namespace esphome::esp32_power_management { + +ESPHOME_LOG_TAG(TAG, "esp32_power_management"); + +void ESP32PowerManagement::setup() { + if (this->max_freq_mhz_ == 0) + this->max_freq_mhz_ = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ; + if (this->min_freq_mhz_ == 0) + this->min_freq_mhz_ = esp_clk_xtal_freq() / MHZ; + // A low boot frequency (ESP32-H2 at 16 MHz) can sit below the crystal frequency. + if (this->min_freq_mhz_ > this->max_freq_mhz_) + this->min_freq_mhz_ = this->max_freq_mhz_; + + esp_pm_config_t pm_config = { + .max_freq_mhz = this->max_freq_mhz_, + .min_freq_mhz = this->min_freq_mhz_, +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + .light_sleep_enable = true, +#endif + }; + esp_err_t rc = esp_pm_configure(&pm_config); + if (rc != ESP_OK) { + // The usual cause is a minimum frequency that does not divide this board's crystal + ESP_LOGE(TAG, "esp_pm_configure failed: %s (crystal %u MHz)", esp_err_to_name(rc), + static_cast(esp_clk_xtal_freq() / MHZ)); + this->mark_failed(); + } +} + +void ESP32PowerManagement::dump_config() { + ESP_LOGCONFIG(TAG, + "Power Management:\n" + " Max Frequency: %uMHZ\n" + " Min Frequency: %uMHZ", + this->max_freq_mhz_, this->min_freq_mhz_); +#if SOC_CLK_XTAL32K_SUPPORTED + ESP_LOGCONFIG(TAG, " 32k XTAL: %s", + rtc_clk_slow_src_get() == SOC_RTC_SLOW_CLK_SRC_XTAL32K ? LOG_STR_LITERAL("in use") + : LOG_STR_LITERAL("not in use")); +#endif +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + ESP_LOGCONFIG(TAG, " Light Sleep Enabled"); +#if CONFIG_ESP_SLEEP_POWER_DOWN_FLASH + ESP_LOGCONFIG(TAG, " PM Flash Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP + ESP_LOGCONFIG(TAG, " PM Peripheral Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_IEEE802154_SLEEP_ENABLE + ESP_LOGCONFIG(TAG, " ieee802154 Sleep Enabled"); +#endif +#if CONFIG_PM_PROFILING + ESP_LOGCONFIG(TAG, " PM Profiling Enabled"); +#endif +#if CONFIG_PM_TRACE + ESP_LOGCONFIG(TAG, " PM Trace Enabled"); +#endif +#if defined(CONFIG_LWIP_IPV6) && !defined(CONFIG_LWIP_ND6) + ESP_LOGCONFIG(TAG, " IPv6 Neighbor Discovery Disabled (CONFIG_LWIP_ND6)"); +#endif +#if CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION + ESP_LOGCONFIG(TAG, " USB Serial/JTAG Light Sleep Prevention Enabled"); +#endif +#endif +} + +} // namespace esphome::esp32_power_management +#endif diff --git a/esphome/components/esp32_power_management/esp32_power_management.h b/esphome/components/esp32_power_management/esp32_power_management.h new file mode 100644 index 0000000000..e6194e7681 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.h @@ -0,0 +1,28 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/power_management/power_management.h" +// Lets a YAML lambda call esp_pm_dump_locks(). +#include "esp_pm.h" + +namespace esphome::esp32_power_management { + +class ESP32PowerManagement : public power_management::PowerManagementComponent { + public: + float get_setup_priority() const override { return setup_priority::POWER; } + void setup() override; + void dump_config() override; + void set_max_freq_mhz(uint16_t max_freq_mhz) { this->max_freq_mhz_ = max_freq_mhz; } + void set_min_freq_mhz(uint16_t min_freq_mhz) { this->min_freq_mhz_ = min_freq_mhz; } + + protected: + // Zero means the build default; setup() replaces it with the value applied. + uint16_t max_freq_mhz_{0}; + uint16_t min_freq_mhz_{0}; +}; + +} // namespace esphome::esp32_power_management + +#endif diff --git a/esphome/components/esp32_power_management/power_management.py b/esphome/components/esp32_power_management/power_management.py new file mode 100644 index 0000000000..e9b6d6462c --- /dev/null +++ b/esphome/components/esp32_power_management/power_management.py @@ -0,0 +1,254 @@ +import logging + +import esphome.codegen as cg +from esphome.components import esp32, power_management +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, + get_esp32_variant, +) +from esphome.components.openthread.const import CONF_POLL_PERIOD +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN +from esphome.components.zigbee import DOMAIN as ZIGBEE_DOMAIN +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_OPENTHREAD, CONF_PLATFORM +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@rwrozelle"] +DEPENDENCIES = ["esp32"] + +CONF_ENABLE_LIGHT_SLEEP = "enable_light_sleep" +CONF_IDLE_TIME_BEFORE_SLEEP = "idle_time_before_sleep" +CONF_MAX_FREQUENCY = "max_frequency" +CONF_MIN_FREQUENCY = "min_frequency" +CONF_POWER_DOWN_FLASH = "power_down_flash" +CONF_POWER_DOWN_PERIPHERALS = "power_down_peripherals" +CONF_PROFILING = "profiling" +CONF_TRACE = "trace" + +# Variants with a TOP power domain, which can power down peripherals in light sleep. +_TOP_PD_VARIANTS = [ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S31, +] + +# Crystal frequencies each variant can be built for (ESP-IDF's SOC_XTAL_FREQ_* values). +# Below the PLL frequencies the CPU runs from the crystal through an integer divider. +_XTAL_MHZ = { + VARIANT_ESP32: (24, 26, 40), + VARIANT_ESP32C2: (26, 32, 40), + VARIANT_ESP32C3: (32, 40), + VARIANT_ESP32C5: (40, 48), + VARIANT_ESP32C6: (40,), + VARIANT_ESP32C61: (40,), + VARIANT_ESP32H2: (32,), + VARIANT_ESP32H21: (32,), + VARIANT_ESP32H4: (32,), + VARIANT_ESP32P4: (40,), + VARIANT_ESP32S2: (40,), + VARIANT_ESP32S3: (32, 40), + VARIANT_ESP32S31: (40,), +} +# esp_pm_configure() keeps the 1 MHz reference clock reachable: its divider must be +# at least 10 on the ESP32 and 2 on every other variant. +_MIN_FREQUENCY_FLOOR_MHZ = {VARIANT_ESP32: 10} + +esp32_power_management_ns = cg.esphome_ns.namespace("esp32_power_management") +PowerManagement = esp32_power_management_ns.class_( + "ESP32PowerManagement", power_management.PowerManagementComponent +) + + +def _mhz(value: object) -> int: + """Validate a frequency and keep it as whole megahertz.""" + return int(cv.frequency(value)) // 1_000_000 + + +def valid_min_frequencies(variant: str) -> list[int]: + """The minimum CPU frequencies esp_pm_configure() accepts on a variant, in MHz. + + A value must divide one of the variant's crystal frequencies, stay above the + reference clock floor, or be one of the PLL frequencies. + """ + floor = _MIN_FREQUENCY_FLOOR_MHZ.get(variant, 2) + mhz = { + xtal // divider + for xtal in _XTAL_MHZ[variant] + for divider in range(1, xtal // floor + 1) + if xtal % divider == 0 + } + mhz.update(int(freq[:-3]) for freq in esp32.CPU_FREQUENCIES[variant]) + return sorted(mhz) + + +def _validate(config: ConfigType) -> ConfigType: + variant = get_esp32_variant() + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + valid = esp32.CPU_FREQUENCIES[variant] + if f"{max_mhz}MHZ" not in valid: + raise cv.Invalid( + f"{max_mhz}MHZ is not a valid CPU frequency for {variant}. " + f"Valid options are: {', '.join(valid)}", + path=[CONF_MAX_FREQUENCY], + ) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and ( + min_mhz not in (valid_min := valid_min_frequencies(variant)) + ): + raise cv.Invalid( + f"{min_mhz}MHZ is not a valid minimum frequency for {variant}; it must " + f"divide the crystal frequency. Valid options are: " + f"{', '.join(f'{mhz}MHZ' for mhz in valid_min)}", + path=[CONF_MIN_FREQUENCY], + ) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + for key in (CONF_POWER_DOWN_PERIPHERALS, CONF_POWER_DOWN_FLASH): + if config.get(key): + raise cv.Invalid( + f"{key} requires {CONF_ENABLE_LIGHT_SLEEP}: true", path=[key] + ) + config[CONF_POWER_DOWN_PERIPHERALS] = False + elif CONF_POWER_DOWN_PERIPHERALS not in config: + config[CONF_POWER_DOWN_PERIPHERALS] = variant in _TOP_PD_VARIANTS + elif config[CONF_POWER_DOWN_PERIPHERALS] and variant not in _TOP_PD_VARIANTS: + raise cv.Invalid( + f"{CONF_POWER_DOWN_PERIPHERALS} is only available on " + f"{', '.join(_TOP_PD_VARIANTS)}", + path=[CONF_POWER_DOWN_PERIPHERALS], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PowerManagement), + cv.Optional(CONF_MAX_FREQUENCY): _mhz, + cv.Optional(CONF_MIN_FREQUENCY): _mhz, + cv.Optional(CONF_ENABLE_LIGHT_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_IDLE_TIME_BEFORE_SLEEP, default=3): cv.int_range( + min=2, max=4294967295 + ), + cv.Optional(CONF_POWER_DOWN_PERIPHERALS): cv.boolean, + cv.Optional(CONF_POWER_DOWN_FLASH, default=False): cv.boolean, + cv.Optional(CONF_PROFILING, default=False): cv.boolean, + # Repurposes GPIOs as trace outputs, so keep it out of the visual editor. + cv.Optional( + CONF_TRACE, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await power_management.register_power_management(var, config) + add_idf_sdkconfig_option("CONFIG_PM_ENABLE", True) + + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + cg.add(var.set_max_freq_mhz(max_mhz)) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None: + cg.add(var.set_min_freq_mhz(min_mhz)) + if config[CONF_PROFILING]: + add_idf_sdkconfig_option("CONFIG_PM_PROFILING", True) + if config[CONF_TRACE]: + add_idf_sdkconfig_option("CONFIG_PM_TRACE", True) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + return + + # Automatic light sleep whenever no task is runnable. + add_idf_sdkconfig_option("CONFIG_FREERTOS_USE_TICKLESS_IDLE", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_MAC_BB_PD", True) + # Kconfig's own "depends on" no-ops this where it doesn't apply. + add_idf_sdkconfig_option("CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION", True) + add_idf_sdkconfig_option( + "CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP", config[CONF_IDLE_TIME_BEFORE_SLEEP] + ) + if config[CONF_POWER_DOWN_PERIPHERALS]: + add_idf_sdkconfig_option("CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP", True) + if config[CONF_POWER_DOWN_FLASH]: + add_idf_sdkconfig_option("CONFIG_ESP_SLEEP_POWER_DOWN_FLASH", True) + + ot_conf = CORE.config.get(CONF_OPENTHREAD) + # A polling (sleepy) Thread end device cannot answer neighbour solicitations. + if ( + ot_conf is not None + and (poll_period := ot_conf.get(CONF_POLL_PERIOD)) is not None + and poll_period.total_milliseconds > 0 + ): + add_idf_sdkconfig_option("CONFIG_LWIP_ND6", False) + if ot_conf is not None or ZIGBEE_DOMAIN in CORE.config: + add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True) + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + entries = full_config.get(power_management.DOMAIN, []) + if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1: + raise cv.Invalid(f"Only one {DOMAIN} instance is allowed") + boot_freq = full_config[esp32.DOMAIN][esp32.CONF_CPU_FREQUENCY] + boot_mhz = int(boot_freq[:-3]) + max_mhz = config.get(CONF_MAX_FREQUENCY) + # Without max_frequency the boot frequency is the ceiling esp_pm_configure() gets. + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and min_mhz > ( + max_mhz if max_mhz is not None else boot_mhz + ): + raise cv.Invalid( + f"{CONF_MIN_FREQUENCY} must not be greater than the maximum frequency", + path=[CONF_MIN_FREQUENCY], + ) + if max_mhz is not None: + if boot_mhz > max_mhz: + _LOGGER.info( + "esp32.%s (%s) is higher than %s (%dMHZ); the CPU will be " + "downclocked to %dMHZ as soon as power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + elif boot_mhz < max_mhz: + _LOGGER.warning( + "esp32.%s (%s) is lower than %s (%dMHZ); the CPU may run as " + "high as %dMHZ under load once power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + if config[CONF_POWER_DOWN_FLASH] and PSRAM_DOMAIN in full_config: + raise cv.Invalid( + f"{CONF_POWER_DOWN_FLASH} is not allowed when the device has PSRAM", + path=[CONF_POWER_DOWN_FLASH], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index dc4fc5b47e..7a0d03df52 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -5,6 +5,7 @@ from esphome.components.noise import ( ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, + validate_encryption_key, ) from esphome.components.ota import ( BASE_OTA_SCHEMA, @@ -32,7 +33,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv from esphome.types import ConfigType @@ -99,16 +100,18 @@ def ota_esphome_final_validate(config: ConfigType) -> None: # Encryption blocks conflict only when both pin a key; a bare # `encryption:` (a package/device split) is compatible with a # keyed one, and merge_config yields the keyed result - merged_key = ( - merged_ota_esphome_configs_by_port[conf_port] - .get(CONF_ENCRYPTION, {}) - .get(CONF_KEY) + merged_enc = merged_ota_esphome_configs_by_port[conf_port].get( + CONF_ENCRYPTION, {} ) - other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) - if merged_key and other_key and merged_key != other_key: - raise cv.Invalid( - f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" - ) + other_enc = ota_conf.get(CONF_ENCRYPTION, {}) + for option in (CONF_KEY, CONF_OLD_KEY): + merged_key = merged_enc.get(option) + other_key = other_enc.get(option) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} " + f"{option} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -206,7 +209,8 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - """Resolve the one encryption key per device into the ota block. An explicit ota key must match the api key, a bare block inherits it, - a runtime provisioned api key cannot be inherited. + a runtime provisioned api key cannot be inherited, and an old_key equal + to the resolved key is a leftover from a finished rotation. """ api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) if ota_key := encryption_conf.get(CONF_KEY): @@ -229,12 +233,19 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - ) else: encryption_conf[CONF_KEY] = api_key + if encryption_conf.get(CONF_OLD_KEY) == encryption_conf[CONF_KEY]: + raise cv.Invalid( + f"'{CONF_OLD_KEY}' is the same as '{CONF_KEY}'; remove '{CONF_OLD_KEY}' " + "once the device runs the new key" + ) # Uploader side options live only on the ota block; the api block keeps the -# shared schema +# shared schema. The firmware is built with `key` alone, `old_key` never +# reaches the device _ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( { + cv.Optional(CONF_OLD_KEY): cv.sensitive(validate_encryption_key), cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, } ) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 58915ff96d..31e4952d8e 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -214,6 +214,15 @@ CLK_MODES = { spi_host_device_t = cg.global_ns.enum("spi_host_device_t") +# Must match the member initializers in ethernet_component.h; setters are skipped +# when the config already equals them. +DEFAULT_SPI_INTERFACE = "spi2" +DEFAULT_PHY_ADDR = 0 +DEFAULT_MDC_PIN = 23 +DEFAULT_MDIO_PIN = 18 +DEFAULT_CLK_MODE = "CLK_EXT_IN" +DEFAULT_CLK_PIN = 0 + SPI_INTERFACE_MAP = { "spi2": spi_host_device_t.SPI2_HOST, "spi3": spi_host_device_t.SPI3_HOST, @@ -437,7 +446,9 @@ RMII_SCHEMA = cv.All( " GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17" ), cv.Required(CONF_CLK): CLK_SCHEMA, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -454,7 +465,9 @@ GENERIC_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -683,6 +696,15 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(final_step) +def _add_mdio_setters(var: cg.MockObj, config: ConfigType) -> None: + if (phy_addr := config[CONF_PHY_ADDR]) != DEFAULT_PHY_ADDR: + cg.add(var.set_phy_addr(phy_addr)) + if (mdc_pin := config[CONF_MDC_PIN]) != DEFAULT_MDC_PIN: + cg.add(var.set_mdc_pin(mdc_pin)) + if (mdio_pin := config[CONF_MDIO_PIN]) != DEFAULT_MDIO_PIN: + cg.add(var.set_mdio_pin(mdio_pin)) + + async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, @@ -701,7 +723,8 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) - cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) + if (interface := config[CONF_INTERFACE]) != DEFAULT_SPI_INTERFACE: + cg.add(var.set_interface(SPI_INTERFACE_MAP[interface])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) if CONF_INTERRUPT_PIN in config: cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) @@ -730,18 +753,16 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) elif config[CONF_TYPE] in ("GENERIC", "YT8531"): # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + _add_mdio_setters(var, config) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) _add_phy_registers(var, config) else: - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) - cg.add(var.set_clk_mode(config[CONF_CLK][CONF_MODE])) - cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) + _add_mdio_setters(var, config) + if (clk_mode := config[CONF_CLK][CONF_MODE]) != DEFAULT_CLK_MODE: + cg.add(var.set_clk_mode(clk_mode)) + if (clk_pin := config[CONF_CLK][CONF_PIN]) != DEFAULT_CLK_PIN: + cg.add(var.set_clk_pin(clk_pin)) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) _add_phy_registers(var, config) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 44c4d6733d..8d5552c6cc 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -271,7 +271,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI2_HOST}; + spi_host_device_t interface_{SPI2_HOST}; // Must match DEFAULT_SPI_INTERFACE in __init__.py #ifdef USE_SPI // When set, the SPI bus is owned and initialized by this spi component // and the ethernet chip only adds a device to it. @@ -283,16 +283,16 @@ class EthernetComponent final : public Component { #else // Group all 32-bit members first int power_pin_{-1}; - emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; + emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; // Must match DEFAULT_CLK_MODE in __init__.py #ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT StaticVector phy_registers_{}; #endif // Group all 8-bit members together - uint8_t clk_pin_{0}; - uint8_t phy_addr_{0}; - uint8_t mdc_pin_{23}; - uint8_t mdio_pin_{18}; + uint8_t clk_pin_{0}; // Must match DEFAULT_CLK_PIN in __init__.py + uint8_t phy_addr_{0}; // Must match DEFAULT_PHY_ADDR in __init__.py + uint8_t mdc_pin_{23}; // Must match DEFAULT_MDC_PIN in __init__.py + uint8_t mdio_pin_{18}; // Must match DEFAULT_MDIO_PIN in __init__.py #endif // USE_ETHERNET_SPI // ESP32 pointers diff --git a/esphome/components/ezsp_proxy_tap/__init__.py b/esphome/components/ezsp_proxy_tap/__init__.py new file mode 100644 index 0000000000..207e0cb8b1 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import serial_proxy +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@puddly"] +DEPENDENCIES = ["serial_proxy"] +DOMAIN = "ezsp_proxy_tap" + +CONF_SERIAL_PROXY_ID = "serial_proxy_id" + +ezsp_proxy_tap_ns = cg.esphome_ns.namespace("ezsp_proxy_tap") +EzspProxyTap = ezsp_proxy_tap_ns.class_( + "EzspProxyTap", cg.Component, serial_proxy.SerialProxyTap +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(EzspProxyTap), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) + + cg.add_define("USE_EZSP_PROXY_TAP") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp new file mode 100644 index 0000000000..f8010f4618 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp @@ -0,0 +1,126 @@ +#include "ash_acknowledger.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +// Control byte of an RSTACK, and the only ASH version byte that can follow it +static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1; +static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02; +static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code +// A DATA frame carries an EZSP frame of at least 3 bytes after its control byte +static constexpr size_t ASH_MIN_DATA_BODY_SIZE = 1 + 3; +static constexpr size_t ASH_CRC_SIZE = 2; +// Smallest legal frame on the wire: a bare control byte plus its CRC +static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE; + +void AshFrameScanner::reset() { + this->index_ = 0; + this->crc_ = ASH_CRC_INIT; + this->escaped_ = false; + this->poisoned_ = false; + this->discarding_ = false; +} + +bool AshFrameScanner::feed(uint8_t byte) { + if (byte == ASH_FLAG_BYTE) { + // Running the CRC over the body and its trailing CRC bytes leaves zero when correct + const bool valid = !this->discarding_ && !this->poisoned_ && !this->escaped_ && + this->index_ >= ASH_MIN_FRAME_SIZE && this->crc_ == 0; + if (valid) { + this->frame_length_ = this->index_ - ASH_CRC_SIZE; + } + this->reset(); + return valid; + } + + // CANCEL also ends a SUBSTITUTE's discard, so the bytes after it start a new frame + if (byte == ASH_CANCEL_BYTE) { + this->reset(); + return false; + } + + if (this->discarding_ || (byte == ASH_WAKE_BYTE && this->index_ == 0)) { + return false; + } + + switch (byte) { + case ASH_SUBSTITUTE_BYTE: + this->discarding_ = true; + return false; + + case ASH_XON_BYTE: + case ASH_XOFF_BYTE: + return false; + + case ASH_ESCAPE_BYTE: + this->escaped_ = true; + return false; + + default: + break; + } + + uint8_t value = byte; + if (this->escaped_) { + this->escaped_ = false; + value = byte ^ ASH_XOR_BYTE; + } + + if (this->index_ >= MAX_ASH_FRAME_SIZE) { + this->poisoned_ = true; + return false; + } + + if (this->index_ < ASH_HEADER_SIZE) { + this->header_[this->index_] = value; + } + this->index_++; + this->crc_ = crc16be(&value, 1, this->crc_); + return false; +} + +void AshAcknowledger::reset() { + this->scanner_.reset(); + this->rx_sequence_ = 0; + this->synced_ = false; +} + +bool AshAcknowledger::feed(uint8_t byte) { return this->scanner_.feed(byte) && this->handle_frame_(); } + +bool AshAcknowledger::handle_frame_() { + const uint8_t *body = this->scanner_.header(); + const uint8_t control = body[0]; + + // An RSTACK restarts the NCP's frame numbering + if (control == ASH_RSTACK_CONTROL) { + if (this->scanner_.length() == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION) { + this->rx_sequence_ = 0; + this->synced_ = true; + } + return false; + } + + if ((control & 0x80) != 0) { + return false; // ACK/NAK/RST/ERROR: nothing is owed for these + } + + if (this->scanner_.length() < ASH_MIN_DATA_BODY_SIZE) { + return false; + } + + const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE; + const bool re_tx = (control & 0x08) != 0; + // Without an RSTACK, the session began before we were watching: follow the NCP's numbering + if (!this->synced_) { + this->rx_sequence_ = frame_num; + this->synced_ = true; + } + if (frame_num == this->rx_sequence_) { + this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; + return true; + } + return re_tx; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.h b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h new file mode 100644 index 0000000000..1c39e8ec4a --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ash_protocol.h" + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames. Only +// the first ASH_HEADER_SIZE bytes of a frame are kept, as the CRC is checked as bytes +// arrive and acknowledging needs nothing past the header. +class AshFrameScanner { + public: + // Returns true when the byte completes a frame with a valid CRC + bool feed(uint8_t byte); + void reset(); + + // The first min(length(), ASH_HEADER_SIZE) bytes of the last completed frame's body. + // Valid only until the next feed() call, which begins overwriting them. + const uint8_t *header() const { return this->header_; } + size_t length() const { return this->frame_length_; } + + private: + uint8_t header_[ASH_HEADER_SIZE]; + size_t index_{0}; // body bytes read so far in the frame being read + size_t frame_length_{0}; // body length of the last completed frame + uint16_t crc_{ASH_CRC_INIT}; + bool escaped_{false}; + bool discarding_{false}; + bool poisoned_{false}; +}; + +// Works out which acknowledgements an NCP is owed while a client has handed them to this +// device. Whether to acknowledge at all is the client's decision, made by putting the port +// in PROTOCOL mode, so this only follows the NCP's frame numbering. +class AshAcknowledger { + public: + // Forgets the NCP's frame numbering. The next RSTACK or DATA frame sets it again. + void reset(); + + // Feed a byte from the NCP. Returns true when it completes a frame that is owed ack_num(). + bool feed(uint8_t byte); + uint8_t ack_num() const { return this->rx_sequence_; } + + protected: + bool handle_frame_(); + + AshFrameScanner scanner_; + uint8_t rx_sequence_{0}; + bool synced_{false}; +}; + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.cpp b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp new file mode 100644 index 0000000000..bbe9cacc2d --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp @@ -0,0 +1,19 @@ +#include "ash_protocol.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num) { + // ACK control byte: 100nrPPP, where PPP is the next frame number expected + const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE); + const uint16_t crc = crc16be(&control, 1, ASH_CRC_INIT); + + output[0] = ASH_FLAG_BYTE; + output[1] = control; + output[2] = crc >> 8; + output[3] = crc & 0xFF; + output[4] = ASH_FLAG_BYTE; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.h b/esphome/components/ezsp_proxy_tap/ash_protocol.h new file mode 100644 index 0000000000..4694c74501 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// ASH Protocol Constants +static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter +static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte +static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes +static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes + +static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission +static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission +static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it +// Sent between frames by sleepy NCPs to signal pending callbacks. No control byte is 0xFF. +static constexpr uint8_t ASH_WAKE_BYTE = 0xFF; + +static constexpr size_t ASH_MAX_DATA_FIELD_SIZE = 220; +static constexpr size_t MAX_ASH_FRAME_SIZE = 1 + ASH_MAX_DATA_FIELD_SIZE + 2; + +// The control byte, then the first Data Field byte (an RSTACK's version) +static constexpr size_t ASH_HEADER_SIZE = 2; + +// Protocol limits +static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) + +// The frame CRC is CRC-CCITT: crc16be() with this initial value, transmitted big-endian +static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; + +// FLAG, control byte, two CRC bytes, FLAG. None of the eight ACKs has a byte that needs +// escaping, so the size is fixed. +static constexpr size_t ASH_ACK_FRAME_SIZE = 5; + +// Writes the ACK frame for `ack_num` into `output` +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num); + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp new file mode 100644 index 0000000000..c8557fcc9b --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp @@ -0,0 +1,38 @@ +#include "ezsp_proxy_tap.h" + +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/core/log.h" + +namespace esphome::ezsp_proxy_tap { + +ESPHOME_LOG_TAG(TAG, "ezsp_proxy_tap"); + +void EzspProxyTap::setup() { this->parent_->set_tap(this); } + +void EzspProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "EZSP Proxy Tap:\n Port: %s", this->parent_->get_name()); } + +void EzspProxyTap::on_device_rx(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (this->acknowledger_.feed(data[i])) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, this->acknowledger_.ack_num()); + this->parent_->write_from_tap(frame, sizeof(frame)); + ESP_LOGV(TAG, "Sent ACK %u", this->acknowledger_.ack_num()); + } + } +} + +void EzspProxyTap::on_protocol_enabled() { + // Numbering from an earlier session is stale + this->acknowledger_.reset(); +} + +void EzspProxyTap::on_device_disconnected() { + // The frame numbering ended with the NCP's power; whatever boots next starts over + this->acknowledger_.reset(); +} + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h new file mode 100644 index 0000000000..2e5a444bec --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/components/serial_proxy/serial_proxy.h" +#include "esphome/core/component.h" +#include "ash_acknowledger.h" + +namespace esphome::ezsp_proxy_tap { + +// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's +// ack timeout is measured against this device rather than against the network round trip +// to the client. The client suppresses its own acknowledgements, making these the only +// ones the NCP sees. +// +// It never carries client traffic: the serial proxy owns the port and the bytes, and this +// component only observes them. The sole exception is the acknowledgement itself, sent +// only while a client has put the port in PROTOCOL mode, which only an EZSP client does. +class EzspProxyTap : public serial_proxy::SerialProxyTap, public Component { + public: + explicit EzspProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + // ACKs depend only on what the NCP sends + void on_client_tx(const uint8_t *data, size_t len) override {} + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } + void on_protocol_enabled() override; + // Nothing is acknowledged outside PROTOCOL mode, and entering it starts afresh + void on_protocol_disabled() override {} + void on_device_disconnected() override; + + protected: + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_; + + AshAcknowledger acknowledger_; +}; + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ac5469fc42..cb54e89c3c 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -64,9 +64,6 @@ RESTORE_MODES = { # Actions CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) -FanStateTrigger = fan_ns.class_( - "FanStateTrigger", automation.Trigger.template(Fan.operator("ptr")) -) FanTurnOnTrigger = fan_ns.class_("FanTurnOnTrigger", automation.Trigger.template()) FanTurnOffTrigger = fan_ns.class_("FanTurnOffTrigger", automation.Trigger.template()) FanDirectionSetTrigger = fan_ns.class_( @@ -115,11 +112,7 @@ _FAN_SCHEMA = ( cv.Optional(CONF_SPEED_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanStateTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanTurnOnTrigger), @@ -221,6 +214,17 @@ def validate_preset_modes(value): return value +# The edge and set triggers keep their classes; each tracks the previous value. +_TRIGGER_AUTOMATIONS = ( + (CONF_ON_TURN_ON, []), + (CONF_ON_TURN_OFF, []), + (CONF_ON_DIRECTION_SET, [(FanDirection, "x")]), + (CONF_ON_OSCILLATING_SET, [(cg.bool_, "x")]), + (CONF_ON_SPEED_SET, [(cg.int_, "x")]), + (CONF_ON_PRESET_SET, [(cg.StringRef, "x")]), +) + + @setup_entity("fan") async def setup_fan_core_(var, config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) @@ -266,26 +270,10 @@ async def setup_fan_core_(var, config): await web_server.add_entity_config(var, web_server_config) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(Fan.operator("ptr"), "x")], conf) - for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_DIRECTION_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(FanDirection, "x")], conf) - for conf in config.get(CONF_ON_OSCILLATING_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.bool_, "x")], conf) - for conf in config.get(CONF_ON_SPEED_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.int_, "x")], conf) - for conf in config.get(CONF_ON_PRESET_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.StringRef, "x")], conf) + await automation.build_parent_callback_automation( + var, "add_on_state_callback", (Fan.operator("ptr"), "x"), conf + ) + await automation.build_trigger_automations(var, config, _TRIGGER_AUTOMATIONS) async def register_fan(var, config): diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 4c587904b7..e5ce09172b 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -50,16 +50,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -class FanStateTrigger final : public Trigger { - public: - FanStateTrigger(Fan *state) : fan_(state) { - state->add_on_state_callback([this]() { this->trigger(this->fan_); }); - } - - protected: - Fan *fan_; -}; - class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 0e522ced25..e496e9c635 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -81,8 +81,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_IDLE_PERIOD_TO_SLEEP ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_PASSWORD): cv.uint32_t, - cv.Optional(CONF_NEW_PASSWORD): cv.uint32_t, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.uint32_t), + cv.Optional(CONF_NEW_PASSWORD): cv.sensitive(cv.uint32_t), cv.Optional(CONF_ON_FINGER_SCAN_START): automation.validate_automation({}), cv.Optional(CONF_ON_FINGER_SCAN_MATCHED): automation.validate_automation( {} diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 766cdc4afb..481b8d01da 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -36,11 +36,17 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - if CONF_INTERLOCK in config: + if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") - interlock = [] - for it in config[CONF_INTERLOCK]: - lock = await cg.get_variable(it) - interlock.append(lock) - cg.add(var.set_interlock(interlock)) + locks = [await cg.get_variable(it) for it in interlock] + # The group's table lists this switch too, so every member shares one table. + group = sorted({str(s): s for s in (var, *locks)}.values(), key=str) + if len(group) > 1: + table = cg.shared_progmem_array( + "gpio_interlock", + switch.Switch.operator("ptr"), + cg.ArrayInitializer(*group), + constexpr=False, + ) + cg.add(var.set_interlock(table, len(group))) cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME])) diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 450a97eb19..68086d9466 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -67,8 +67,4 @@ void GPIOSwitch::write_state(bool state) { this->publish_state(state); } -#ifdef USE_GPIO_SWITCH_INTERLOCK -void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } -#endif - } // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index e7323e6e93..64e4f95f16 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -21,7 +21,10 @@ class GPIOSwitch final : public switch_::Switch, public Component { void setup() override; void dump_config() override; #ifdef USE_GPIO_SWITCH_INTERLOCK - void set_interlock(const std::initializer_list &interlock); + /// Codegen only: the table must outlive this switch; it may list this switch, which is skipped. + void set_interlock(Switch *const *interlock, size_t count) { + this->interlock_ = ConstVector(interlock, count); + } void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } #endif @@ -30,7 +33,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK - FixedVector interlock_; + ConstVector interlock_; uint32_t interlock_wait_time_{0}; #endif }; diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 07dd325a2c..388b8165fd 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -24,9 +24,13 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned return; \ } +// Reset sequence (Goodix GT911 datasheet): INT held low while RST is released selects address 0x5D, then +// T3 + T4 pass before INT is handed back to the chip as its interrupt output +static constexpr uint32_t RESET_LOW_MS = 10; +static constexpr uint32_t RESET_SETTLE_MS = 5 + 50 + 1; + void GT911Touchscreen::setup() { if (this->reset_pin_ != nullptr) { - // temporarily set the interrupt pin to output to control address selection this->reset_pin_->setup(); this->reset_pin_->digital_write(false); if (this->interrupt_pin_ != nullptr) { @@ -34,20 +38,19 @@ void GT911Touchscreen::setup() { this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT); this->interrupt_pin_->digital_write(false); } - delay(2); - this->reset_pin_->digital_write(true); - // wait at least T3+T4 ms as per the datasheet - this->set_timeout(5 + 50 + 1, [this] { this->setup_internal_(); }); + this->set_timeout(RESET_LOW_MS, [this] { + this->reset_pin_->digital_write(true); + this->set_timeout(RESET_SETTLE_MS, [this] { this->setup_internal_(); }); + }); return; } this->setup_internal_(); } void GT911Touchscreen::setup_internal_() { - if (this->interrupt_pin_ != nullptr) { - if (this->interrupt_pin_->is_internal()) - this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); - } + // Expander pins must leave output mode too, or the chip cannot drive its interrupt line + if (this->interrupt_pin_ != nullptr) + this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); uint8_t data[4]; i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); @@ -72,7 +75,7 @@ void GT911Touchscreen::setup_internal_() { ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } else { - // IO expander pin: leave as output for configuration only. + // IO expander pin: no hardware interrupt, the touch state is polled. ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 465df528e5..13fb6b05da 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -14,17 +14,8 @@ class GT911ButtonListener { class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: - /// @brief Initialize the GT911 touchscreen. - /// - /// If @ref reset_pin_ is set, the touchscreen will be hardware reset, - /// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout() - /// to allow the device to stabilize after reset. - /// - /// If @ref interrupt_pin_ is set, it will be temporarily configured during reset - /// to control I2C address selection. - /// - /// After the timeout, or immediately if no reset is performed, @ref setup_internal_() - /// is called to complete the initialization. + /// With a reset pin the chip is reset with the interrupt pin held low, which selects address 0x5D, and the + /// rest of the setup runs from timeouts once the chip is ready. Without one setup_internal_() runs at once. void setup() override; void dump_config() override; bool can_proceed() override { return this->setup_done_; } diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 2376a67416..b4597fe4ab 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -1,5 +1,7 @@ #include "heatpumpir.h" +#include "receiver_mitsubishi_heavy.h" + #if defined(USE_ARDUINO) || defined(USE_ESP32) #include @@ -125,6 +127,13 @@ void HeatpumpIRClimate::setup() { } } +void HeatpumpIRClimate::control(const climate::ClimateCall &call) { + // Picking a fan speed ends HiPower or Econo, as it does on the remote + if (this->has_fan_byte_presets_() && call.get_fan_mode().has_value() && !call.get_preset().has_value()) + this->preset = climate::CLIMATE_PRESET_NONE; + climate_ir::ClimateIR::control(call); +} + void HeatpumpIRClimate::transmit_state() { uint8_t power_mode_cmd; uint8_t operating_mode_cmd; @@ -188,8 +197,9 @@ void HeatpumpIRClimate::transmit_state() { } // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, - // so low/medium/high have to start at FAN_1 there instead of FAN_2. - const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; + // so low/medium/high have to start at FAN_1 there instead of FAN_2. Mitsubishi Heavy + // ZJ and ZMP also stop at FAN_3 and use FAN_4 and FAN_5 for HiPower and Econo. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC || this->has_fan_byte_presets_(); switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; @@ -206,6 +216,19 @@ void HeatpumpIRClimate::transmit_state() { break; } + if (this->has_fan_byte_presets_()) { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + fan_speed_cmd = FAN_4; // HiPower + break; + case climate::CLIMATE_PRESET_ECO: + fan_speed_cmd = FAN_5; // Econo + break; + default: + break; + } + } + switch (this->mode) { case climate::CLIMATE_MODE_COOL: power_mode_cmd = POWER_ON; @@ -246,6 +269,23 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd); } +bool HeatpumpIRClimate::on_receive(remote_base::RemoteReceiveData data) { + switch (this->protocol_) { + case PROTOCOL_MITSUBISHI_HEAVY_ZMP: { + uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]; + if (!decode_mitsubishi_heavy_frame(data, frame, *this)) + return false; + decode_mitsubishi_heavy_zmp(frame, *this); + break; + } + default: + return false; // no decoder for this protocol + } + this->target_temperature = clamp(this->target_temperature, this->min_temperature_, this->max_temperature_); + this->publish_state(); + return true; +} + } // namespace esphome::heatpumpir #endif diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 8e0668d59d..d00fa91d05 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -102,7 +102,12 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} void setup() override; - void set_protocol(Protocol protocol) { this->protocol_ = protocol; } + void set_protocol(Protocol protocol) { + this->protocol_ = protocol; + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -115,8 +120,15 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { protected: HeatpumpIR *heatpump_ir_; + void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. void transmit_state() override; + /// Mitsubishi Heavy ZJ and ZMP carry HiPower and Econo in the fan byte as FAN_4 and FAN_5 + bool has_fan_byte_presets_() const { + return this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZJ || this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZMP; + } + /// Handle received IR data from the remote control. + bool on_receive(remote_base::RemoteReceiveData data) override; Protocol protocol_; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp new file mode 100644 index 0000000000..004eb46c00 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp @@ -0,0 +1,109 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +static constexpr uint8_t FRAME_PREFIX[5] = {0x52, 0xAE, 0xC3, 0x26, 0xD9}; +static constexpr uint8_t XOR_CHECKSUM_EXPECTED = 0xFF; +static constexpr uint8_t PREFIX_LENGTH = 5; +static constexpr uint8_t MODE_TEMP_BYTE = 9; +static constexpr uint8_t MODE_MASK = 0x07; +static constexpr uint8_t TEMP_MASK = 0x0F; +static constexpr uint8_t TEMPERATURE_BASE = 17; +static constexpr uint8_t TEMPERATURE_SHIFT = 4; + +static bool read_raw_bytes(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + if (!data.expect_item(MITSUBISHI_HEAVY_HDR_MARK, MITSUBISHI_HEAVY_HDR_SPACE)) + return false; + + for (uint8_t i = 0; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ONE_SPACE)) { + byte |= 1 << bit; + } else if (!data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ZERO_SPACE)) { + return false; + } + } + frame[i] = byte; + } + + return true; +} + +static bool validate_prefix(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = 0; i < PREFIX_LENGTH; i++) { + if (frame[i] != FRAME_PREFIX[i]) + return false; + } + return true; +} + +static bool validate_checksums(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = PREFIX_LENGTH; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i += 2) { + if ((uint8_t) (frame[i] ^ frame[i + 1]) != XOR_CHECKSUM_EXPECTED) + return false; + } + return true; +} + +static bool decode_mode_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], climate::ClimateMode &mode) { + if (frame[MODE_TEMP_BYTE] & MITSUBISHI_HEAVY_MODE_OFF) { + mode = climate::CLIMATE_MODE_OFF; + return true; + } + switch (frame[MODE_TEMP_BYTE] & MODE_MASK) { + case MITSUBISHI_HEAVY_MODE_AUTO: + mode = climate::CLIMATE_MODE_HEAT_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_HEAT: + mode = climate::CLIMATE_MODE_HEAT; + return true; + case MITSUBISHI_HEAVY_MODE_COOL: + mode = climate::CLIMATE_MODE_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_DRY: + mode = climate::CLIMATE_MODE_DRY; + return true; + case MITSUBISHI_HEAVY_MODE_FAN: + mode = climate::CLIMATE_MODE_FAN_ONLY; + return true; + default: + return false; + } +} + +static float decode_temperature_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + uint8_t temp_nibble = frame[MODE_TEMP_BYTE] >> TEMPERATURE_SHIFT; + uint8_t inverted_nibble = ~temp_nibble; + uint8_t temp_offset = inverted_nibble & TEMP_MASK; + return TEMPERATURE_BASE + temp_offset; +} + +static bool apply_climate_state(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + climate::ClimateMode mode; + if (!decode_mode_field(frame, mode)) + return false; + climate.mode = mode; + climate.target_temperature = decode_temperature_field(frame); + return true; +} + +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate) { + if (!read_raw_bytes(data, frame)) + return false; + if (!validate_prefix(frame)) + return false; + if (!validate_checksums(frame)) + return false; + return apply_climate_state(frame, climate); +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h new file mode 100644 index 0000000000..61e3c34ca4 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::heatpumpir { + +class HeatpumpIRClimate; + +/// Bytes in a Mitsubishi Heavy IR frame, prefix included +inline constexpr uint8_t MITSUBISHI_HEAVY_FRAME_LENGTH = 11; + +/// Read a Mitsubishi Heavy frame into `frame`, check its prefix and inverted byte pairs, and apply the +/// fields every variant shares (mode and target temperature). Returns false when no valid frame was read. +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate); + +/// Apply the ZMP-specific fields (fan, preset, swing) of a validated frame. +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate); + +} // namespace esphome::heatpumpir diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp new file mode 100644 index 0000000000..4d92c35c77 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp @@ -0,0 +1,63 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +// Fan speed sits in bits 7:5 of byte 7; the vertical swing code spans bit 1 of byte 5 and bits 4:3 of byte 7 +static constexpr uint8_t BYTE_SWING_H = 5; +static constexpr uint8_t BYTE_FAN = 7; +static constexpr uint8_t FAN_SPEED_MASK = 0xE0; +static constexpr uint8_t VS_MASK_BYTE5 = 0x02; +static constexpr uint8_t VS_MASK_BYTE7 = 0x18; +// The library has no horizontal swing for ZMP and always sends MITSUBISHI_HEAVY_ZMP_HS_STOP (0xCC) in byte 5. +// Captures of the remote show 0x5C while it swings; the mask leaves out the vertical swing and clean bits. +static constexpr uint8_t HORIZONTAL_SWING_MASK = 0xDC; +static constexpr uint8_t HORIZONTAL_SWING_ACTIVE = 0x5C; + +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + uint8_t fan = frame[BYTE_FAN] & FAN_SPEED_MASK; + climate.preset = climate::CLIMATE_PRESET_NONE; + switch (fan) { + case MITSUBISHI_HEAVY_ZMP_FAN_AUTO: + climate.fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case MITSUBISHI_HEAVY_ZMP_FAN1: + climate.fan_mode = climate::CLIMATE_FAN_LOW; + break; + case MITSUBISHI_HEAVY_ZMP_FAN2: + climate.fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case MITSUBISHI_HEAVY_ZMP_FAN3: + climate.fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case MITSUBISHI_HEAVY_ZMP_HIPOWER: + climate.preset = climate::CLIMATE_PRESET_BOOST; + break; // the remote keeps the fan mode and restores it when the preset ends + case MITSUBISHI_HEAVY_ZMP_ECONO: + climate.preset = climate::CLIMATE_PRESET_ECO; + break; + } + + uint8_t swing_h = frame[BYTE_SWING_H] & HORIZONTAL_SWING_MASK; + uint8_t swing_v = (frame[BYTE_SWING_H] & VS_MASK_BYTE5) | (frame[BYTE_FAN] & VS_MASK_BYTE7); + bool h_swing = (swing_h == HORIZONTAL_SWING_ACTIVE); + bool v_swing = (swing_v == MITSUBISHI_HEAVY_ZMP_VS_SWING); + + if (h_swing && v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (h_swing) { + climate.swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else if (v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + climate.swing_mode = climate::CLIMATE_SWING_OFF; + } +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index da460905c0..7aedf59924 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP from esphome.types import ConfigType @@ -9,7 +10,6 @@ from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] CONF_DOOR_STATE = "door_state" -CONF_SERIAL_NUMBER = "serial_number" HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py new file mode 100644 index 0000000000..92bf8c998f --- /dev/null +++ b/esphome/components/husb238/__init__.py @@ -0,0 +1,33 @@ +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 = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "husb238" +MULTI_CONF = True + +CONF_HUSB238_ID = "husb238_id" + +husb238_ns = cg.esphome_ns.namespace("husb238") +Husb238Component = husb238_ns.class_( + "Husb238Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Husb238Component), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(0x08)) +) + + +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) diff --git a/esphome/components/husb238/binary_sensor.py b/esphome/components/husb238/binary_sensor.py new file mode 100644 index 0000000000..78d5d78a9d --- /dev/null +++ b/esphome/components/husb238/binary_sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, DEVICE_CLASS_PLUG, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_HUSB238_ID, Husb238Component + +DEPENDENCIES = ["husb238"] + +CONF_ATTACHED = "attached" +CONF_CC_DIRECTION = "cc_direction" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HUSB238_ID): cv.use_id(Husb238Component), + cv.Optional(CONF_ATTACHED): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PLUG, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + key=CONF_NAME, + ), + cv.Optional(CONF_CC_DIRECTION): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HUSB238_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_ATTACHED, hub.set_attached_binary_sensor) + await sensors(CONF_CC_DIRECTION, hub.set_cc_direction_binary_sensor) diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp new file mode 100644 index 0000000000..8ca841a7e4 --- /dev/null +++ b/esphome/components/husb238/husb238.cpp @@ -0,0 +1,51 @@ +#include "husb238.h" +#include "esphome/core/log.h" + +namespace esphome::husb238 { + +ESPHOME_LOG_TAG(TAG, "husb238"); + +void Husb238Component::setup() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void Husb238Component::update() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + +#ifdef USE_BINARY_SENSOR + if (this->attached_binary_sensor_ != nullptr) { + this->attached_binary_sensor_->publish_state((status & PD_STATUS1_ATTACHED) != 0); + } + if (this->cc_direction_binary_sensor_ != nullptr) { + // The CC line only means something while a source is attached + if (status & PD_STATUS1_ATTACHED) { + this->cc_direction_binary_sensor_->publish_state((status & PD_STATUS1_CC_DIR) != 0); + } else { + this->cc_direction_binary_sensor_->invalidate_state(); + } + } +#endif +} + +void Husb238Component::dump_config() { + ESP_LOGCONFIG(TAG, "HUSB238:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "PD Attached", this->attached_binary_sensor_); + LOG_BINARY_SENSOR(" ", "CC Direction", this->cc_direction_binary_sensor_); +#endif +} + +} // namespace esphome::husb238 diff --git a/esphome/components/husb238/husb238.h b/esphome/components/husb238/husb238.h new file mode 100644 index 0000000000..5a3513db02 --- /dev/null +++ b/esphome/components/husb238/husb238.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::husb238 { + +static constexpr uint8_t REG_PD_STATUS1 = 0x01; +static constexpr uint8_t PD_STATUS1_ATTACHED = 1 << 6; +static constexpr uint8_t PD_STATUS1_CC_DIR = 1 << 7; + +class Husb238Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(attached) + SUB_BINARY_SENSOR(cc_direction) +#endif +}; + +} // namespace esphome::husb238 diff --git a/esphome/components/i2s_clockless_led_strip/__init__.py b/esphome/components/i2s_clockless_led_strip/__init__.py new file mode 100644 index 0000000000..038e865f3a --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "i2s_clockless_led_strip" diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp new file mode 100644 index 0000000000..bf53d96a83 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -0,0 +1,254 @@ +#include "i2s_clockless_led_strip.h" + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::i2s_clockless_led_strip { + +ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip"); + +constexpr const char *const ERROR_ALLOCATION = "Allocation error"; +constexpr const char *const ERROR_I2S = "I2S error"; + +// These values are hardcoded for an LED strip at 800 kbps. +// +// Each bit of LED strip data gets expanded into 3 bits of I2S data, where a 0-bit expands into 100 +// (1/3 duty cycle) and a 1-bit expands into 110 (2/3 duty cycle. Each byte of LED strip data becomes +// one 24-bit I2S sample to be transmitted at a rate of 100000 samples per second in I2S TDM mode. +constexpr uint32_t I2S_SAMPLE_RATE_HZ = 100000; +constexpr size_t I2S_BYTES_PER_SAMPLE = 3; + +// Determine how many I2S samples are needed to feed the output for the specified duration and vice-versa. +constexpr size_t num_i2s_samples_from_duration_us(uint32_t duration_us) { + return (duration_us * I2S_SAMPLE_RATE_HZ + 999999) / 1000000; +} +constexpr uint32_t num_i2s_samples_to_duration_us(size_t samples) { return samples * 1000000ull / I2S_SAMPLE_RATE_HZ; } + +// Determine the number of I2S samples to pad with zeros after the LED data to encode the LED strip reset signal. +// Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5. +constexpr uint32_t I2S_RESET_DURATION_US = 300; +constexpr size_t I2S_RESET_SAMPLES = num_i2s_samples_from_duration_us(I2S_RESET_DURATION_US); +constexpr size_t I2S_RESET_BYTES = I2S_RESET_SAMPLES * I2S_BYTES_PER_SAMPLE; + +// Determine the number of I2S samples per DMA buffer to ensure that the interrupt-driven I2S on_sent callback +// doesn't run too frequently. For example, if the buffers were sized to hold just 1 RGBW pixel then they would be +// recycled approximately every 40 us (25000 Hz) which is way too fast. The buffer duration also imposes an upper +// bound on the maximum LED strip refresh rate. Round up to a multiple of 3 for `dma_frame_num` as required by +// the ESP-IDF programming guide when using 24-bit samples. +constexpr uint32_t I2S_BUFFER_DURATION_US_IDEAL = 2500; +constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFFER_DURATION_US_IDEAL) + 2) / 3 * 3; +constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES); + +I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors) + : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), + channel_colors_(channel_colors), + num_leds_(num_leds), + pin_(pin) {} + +void I2SClocklessLedStrip::dump_config() { + ESP_LOGCONFIG(TAG, + "I2S Clockless LED Strip:\n" + " Pin: %u", + this->pin_); + char channel_colors[5]; + ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" + " Number of LEDs: %" PRIu16 "\n" + " DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)", + this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, + I2S_BUFFER_DURATION_US_ACTUAL); +} + +float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } + +void I2SClocklessLedStrip::setup() { + if (!this->allocate_buffers_()) { + this->mark_failed(LOG_STR(ERROR_ALLOCATION)); + return; + } + + i2s_chan_config_t chan_config = { + .id = I2S_NUM_AUTO, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 2, + .dma_frame_num = I2S_BUFFER_SAMPLES, + .auto_clear_after_cb = false, + .auto_clear_before_cb = false, + .allow_pd = false, + .intr_priority = 0, + }; + esp_err_t err = i2s_new_channel(&chan_config, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_new_channel: %s", esp_err_to_name(err)); + } else { + i2s_tdm_config_t tdm_config = { + .clk_cfg = + { + .sample_rate_hz = I2S_SAMPLE_RATE_HZ, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_384, + }, + .slot_cfg = + { + .data_bit_width = I2S_DATA_BIT_WIDTH_24BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_TDM_SLOT0, + .ws_width = 1, + .big_endian = true, + .total_slot = I2S_TDM_AUTO_SLOT_NUM, + }, + .gpio_cfg = + { + .mclk = I2S_GPIO_UNUSED, + .bclk = I2S_GPIO_UNUSED, + .ws = I2S_GPIO_UNUSED, + .dout = gpio_num_t(this->pin_), + .din = I2S_GPIO_UNUSED, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_tdm_mode(this->tx_handle_, &tdm_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_init_tdm_mode: %s", esp_err_to_name(err)); + } else { + i2s_event_callbacks_t event_callbacks = { + .on_sent = i2s_on_sent_callback, + }; + err = i2s_channel_register_event_callback(this->tx_handle_, &event_callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err)); + } else { + err = i2s_channel_enable(this->tx_handle_); + if (err == ESP_OK) + return; // Success! + ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err)); + } + } + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = {}; + } + this->mark_failed(LOG_STR(ERROR_I2S)); +} + +bool I2SClocklessLedStrip::allocate_buffers_() { + const size_t i2s_data_bytes = this->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->i2s_data_ = allocator.allocate(i2s_data_bytes); + if (this->i2s_data_ != nullptr) { + this->color_data_ = allocator.allocate(this->color_data_bytes_); + if (this->color_data_ != nullptr) { + this->effect_data_ = allocator.allocate(this->num_leds_); + if (this->effect_data_ != nullptr) { + memset(this->i2s_data_, 0, i2s_data_bytes); + memset(this->color_data_, 0, this->color_data_bytes_); + memset(this->effect_data_, 0, this->num_leds_); + return true; + } + allocator.deallocate(this->color_data_, this->color_data_bytes_); + this->color_data_ = nullptr; + } + allocator.deallocate(this->i2s_data_, i2s_data_bytes); + this->i2s_data_ = nullptr; + } + return false; +} + +light::LightTraits I2SClocklessLedStrip::get_traits() { + auto traits = light::LightTraits(); + if (this->channel_colors_.has_white()) { + traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); + } else { + traits.set_supported_color_modes({light::ColorMode::RGB}); + } + return traits; +} + +void I2SClocklessLedStrip::write_state(light::LightState *state) { + if (this->is_failed()) + return; + + if (this->i2s_data_ready_.load(std::memory_order_acquire)) { + // Busy sending the last frame, try again later. + this->schedule_show(); + return; + } + + const size_t color_data_bytes = this->color_data_bytes_; + uint8_t *color_data = this->color_data_; + uint8_t *i2s_data = this->i2s_data_; + for (size_t i = 0; i < color_data_bytes; i++) { + const uint8_t color_byte = *(color_data++); + *(i2s_data++) = 0b10010010 | ((color_byte & 0x80) >> 1) | ((color_byte & 0x40) >> 3) | ((color_byte & 0x20) >> 5); + *(i2s_data++) = 0b01001001 | ((color_byte & 0x10) << 1) | ((color_byte & 0x08) >> 1); + *(i2s_data++) = 0b00100100 | ((color_byte & 0x04) << 5) | ((color_byte & 0x02) << 3) | ((color_byte & 0x01) << 1); + } + + this->i2s_data_sent_ = 0; + this->i2s_data_ready_.store(true, std::memory_order_release); + + this->mark_shown_(); +} + +bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + auto *const self = static_cast(user_ctx); + auto *const dma_buf = static_cast(event->dma_buf); + const size_t dma_buf_size = event->size; + + bool dma_buf_filled = false; + if (self->i2s_data_ready_.load(std::memory_order_acquire)) { + const size_t i2s_data_bytes = self->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + if (self->i2s_data_sent_ < i2s_data_bytes) { + const size_t i2s_data_remaining = i2s_data_bytes - self->i2s_data_sent_; + if (i2s_data_remaining >= dma_buf_size) { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, dma_buf_size); + } else { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, i2s_data_remaining); + memset(dma_buf + i2s_data_remaining, 0, dma_buf_size - i2s_data_remaining); + } + dma_buf_filled = true; + } + self->i2s_data_sent_ += dma_buf_size; + if (self->i2s_data_sent_ >= i2s_data_bytes + I2S_RESET_BYTES) { + self->i2s_data_ready_.store(false, std::memory_order_release); + } + } + // We only need to clear the buffer when the first byte is non-zero. Zero is not a value + // that can appear within the I2S data that gets copied into the front of the buffer so + // if the first byte is zero then the buffer must have already been cleared. + if (!dma_buf_filled && dma_buf[0] != 0) { + memset(dma_buf, 0, dma_buf_size); + } + return false; +} + +void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); } + +light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const { + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->color_data_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, + &this->effect_data_[index], + &this->correction_}; +} + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h new file mode 100644 index 0000000000..0c516a6bc8 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -0,0 +1,56 @@ +#pragma once + +#ifdef USE_ESP32 +#include +#endif + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" + +#include + +namespace esphome::i2s_clockless_led_strip { + +class I2SClocklessLedStrip final : public light::AddressableLight { + public: + I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors); + + void dump_config() override; + + float get_setup_priority() const override; + void setup() override; + + light::LightTraits get_traits() override; + + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override; + void write_state(light::LightState *state) override; + + protected: + static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + bool allocate_buffers_(); + light::ESPColorView get_view_internal(int32_t index) const override; + + const size_t color_data_bytes_; + uint8_t *effect_data_{nullptr}; + uint8_t *color_data_{nullptr}; + uint8_t *i2s_data_{nullptr}; + size_t i2s_data_sent_{0}; + i2s_chan_handle_t tx_handle_{}; + const light::ChannelColors channel_colors_; + const uint16_t num_leds_; + const uint8_t pin_; + std::atomic i2s_data_ready_{false}; +}; + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/light.py b/esphome/components/i2s_clockless_led_strip/light.py new file mode 100644 index 0000000000..90a4f1c354 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/light.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, light +from esphome.components.const import CONF_CHANNEL_COLORS +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID, CONF_PIN +from esphome.core import CORE +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@j9brown"] +DEPENDENCIES = ["esp32"] + +i2s_clockless_led_strip_ns = cg.esphome_ns.namespace("i2s_clockless_led_strip") +I2SClocklessLedStrip = i2s_clockless_led_strip_ns.class_( + "I2SClocklessLedStrip", light.AddressableLight +) + +# These variants define SOC_I2S_SUPPORTS_TDM +I2S_TDM_PORTS = { + esp32.VARIANT_ESP32C3: 1, + esp32.VARIANT_ESP32C5: 1, + esp32.VARIANT_ESP32C6: 1, + esp32.VARIANT_ESP32C61: 1, + esp32.VARIANT_ESP32H2: 1, + esp32.VARIANT_ESP32H4: 1, + esp32.VARIANT_ESP32P4: 3, + esp32.VARIANT_ESP32S3: 2, +} + +CONFIG_SCHEMA = cv.All( + esp32.only_on_variant( + supported=list(I2S_TDM_PORTS.keys()), + msg_prefix="I2S Clockless LED Strip", + ), + light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(I2SClocklessLedStrip), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, + cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors, + } + ).extend(cv.COMPONENT_SCHEMA), +) + + +@dataclass +class I2SClocklessLedStripData: + num_ports: int = 0 + + +def _get_data() -> I2SClocklessLedStripData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SClocklessLedStripData() + return CORE.data[DOMAIN] + + +def _final_validate(_: ConfigType) -> None: + _get_data().num_ports += 1 + variant = esp32.get_esp32_variant() + num_ports_available = I2S_TDM_PORTS.get(variant, 0) + if _get_data().num_ports > num_ports_available: + raise cv.Invalid( + f"Only {num_ports_available} I2S TDM ports are supported on {variant}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + esp32.include_builtin_idf_component("esp_driver_i2s") + esp32.add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_PIN], + config[CONF_NUM_LEDS], + light.channel_colors_struct(config[CONF_CHANNEL_COLORS]), + ) + + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 9dfc700f74..e7d4be8cc3 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, improv_base, output +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, PLATFORM_ESP32 from esphome.core import CORE @@ -38,7 +39,6 @@ CONF_BLE_SERVER_ID = "ble_server_id" CONF_IDENTIFY_DURATION = "identify_duration" CONF_ON_PROVISIONED = "on_provisioned" CONF_ON_PROVISIONING = "on_provisioning" -CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index be12d0aa38..9b4cc53a2e 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -8,6 +8,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #define ARDUINOJSON_ENABLE_STD_STRING 1 // NOLINT @@ -17,6 +18,17 @@ namespace esphome::json { +// Link a string into the document instead of copying it. Only for a literal, a codegen table +// entry or a name set once during setup; never a stack buffer or an entity state. +inline JsonString linked(const char *s) { return JsonString(s, true); } +inline JsonString linked(const char *s, size_t len) { return JsonString(s, len, true); } +inline JsonString linked(const std::string &s) { return JsonString(s.c_str(), s.size(), true); } +JsonString linked(std::string &&) = delete; // a temporary dies before the document is serialized +#ifdef USE_ESP8266 +// A PROGMEM string is copied out of flash +inline ProgmemStr linked(ProgmemStr s) { return s; } +#endif + /// Buffer for JSON serialization that uses stack allocation for small payloads. /// Template parameter STACK_SIZE specifies the stack buffer size (default 512 bytes). /// Supports move semantics for efficient return-by-value. diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py new file mode 100644 index 0000000000..1c39c7eab3 --- /dev/null +++ b/esphome/components/ld2410s/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@NovakIrs"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld2410s" +MULTI_CONF = True + +CONF_LD2410S_ID = "ld2410s_id" + +ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) +LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2410SComponent), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + baud_rate=115200, + parity="NONE", + stop_bits=1, +) + + +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) diff --git a/esphome/components/ld2410s/binary_sensor.py b/esphome/components/ld2410s/binary_sensor.py new file mode 100644 index 0000000000..83e0a36cba --- /dev/null +++ b/esphome/components/ld2410s/binary_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + icon=ICON_ACCOUNT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp new file mode 100644 index 0000000000..6462236ac9 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -0,0 +1,314 @@ +#include "ld2410s.h" +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ld2410s { + +ESPHOME_LOG_TAG(TAG, "ld2410s"); + +static constexpr uint8_t SHORT_DATA_FRAME_HEADER = 0x6E; +static constexpr uint8_t SHORT_DATA_FRAME_FOOTER[] PROGMEM = {0x62}; +static constexpr uint8_t SHORT_DATA_FRAME_SIZE = 5; // header, state, distance low, distance high, footer +static constexpr uint8_t STD_DATA_FRAME_HEADER[] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t STD_DATA_FRAME_FOOTER[] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; +static constexpr uint8_t CMD_FRAME_HEADER[] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t LONG_HEADER_SIZE = 4; +static constexpr uint8_t LENGTH_FIELD_SIZE = 2; +static constexpr uint8_t LONG_PAYLOAD_POS = LONG_HEADER_SIZE + LENGTH_FIELD_SIZE; + +static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word of an acknowledgement +static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; +static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; +static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; +// Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes +static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; +static constexpr uint8_t CMD_FRAME_MAX_SIZE = + LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); + +// Everything the module needs at start: enter configuration mode, pick the output mode, leave it +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, + CONFIG_MODE_END_CMD}; +static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); +static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } +static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +static constexpr uint32_t ACK_TIMEOUT_MS = 300; +static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command +static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering +static constexpr uint32_t LINK_TIMEOUT_MS = 5000; // the module streams frames continuously; silence means it is gone + +static constexpr uint8_t DATA_TYPE_STANDARD = 0x01; + +void LD2410SComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LD2410S:"); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); +#endif +} + +void LD2410SComponent::loop() { + uint8_t chunk[16]; + // Take the count once so one pass handles a bounded number of bytes + size_t avail = this->available(); + while (avail > 0) { + const size_t len = std::min(avail, sizeof(chunk)); + if (!this->read_array(chunk, len)) { + ESP_LOGV(TAG, "UART read of %u bytes failed", static_cast(len)); + break; + } + for (size_t i = 0; i < len; i++) { + this->receive_byte_(chunk[i]); + } + avail -= len; + } + const uint32_t now = App.get_loop_component_start_time(); + this->run_init_sequence_(now); + if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) + ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, + this->dropped_frames_); + this->dropped_frames_ = 0; + this->status_set_warning(); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; + } +} + +void LD2410SComponent::run_init_sequence_(uint32_t now) { + if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { + return; + } + const uint16_t command = init_command(this->init_step_); + if (this->awaiting_ack_) { + if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, + REINIT_PAUSE_MS / 1000); + // Leave configuration mode whatever state the module is in, then begin again + this->send_command_(CONFIG_MODE_END_CMD); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now + REINIT_PAUSE_MS; + return; + } + ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); + } + this->send_command_(command); + this->awaiting_ack_ = true; + this->next_send_at_ = now + ACK_TIMEOUT_MS; +} + +void LD2410SComponent::send_command_(uint16_t command) { + uint8_t frame[CMD_FRAME_MAX_SIZE]; + progmem_memcpy(frame, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + uint8_t pos = LONG_PAYLOAD_POS; + frame[pos++] = command & 0xFF; + frame[pos++] = command >> 8; + switch (command) { + case OUTPUT_MODE_SWITCH_CMD: + memset(&frame[pos], 0, OUTPUT_MODE_MINIMAL_SIZE); + pos += OUTPUT_MODE_MINIMAL_SIZE; + break; + case CONFIG_MODE_START_CMD: + frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; + frame[pos++] = CONFIG_MODE_START_VALUE >> 8; + break; + default: + break; + } + frame[LONG_HEADER_SIZE] = pos - LONG_PAYLOAD_POS; // payload length, little-endian + frame[LONG_HEADER_SIZE + 1] = 0; + progmem_memcpy(&frame[pos], CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); + pos += sizeof(CMD_FRAME_FOOTER); + this->write_array(frame, pos); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(CMD_FRAME_MAX_SIZE)]; + ESP_LOGVV(TAG, "> %s", format_hex_pretty_to(hex_buf, frame, pos, ' ')); +#endif +} + +void LD2410SComponent::receive_byte_(uint8_t byte) { + if (this->rx_len_ >= RX_BUFFER_SIZE) { + this->reset_frame_(); + } + this->rx_buffer_[this->rx_len_++] = byte; + + if (this->frame_type_ == FrameType::NONE) { + if (this->rx_len_ == 1 && byte == SHORT_DATA_FRAME_HEADER) { + this->frame_type_ = FrameType::SHORT_DATA; + this->expected_len_ = SHORT_DATA_FRAME_SIZE; + return; + } + // The two long headers are told apart once all four bytes are in + uint8_t std_header[LONG_HEADER_SIZE]; + uint8_t cmd_header[LONG_HEADER_SIZE]; + progmem_memcpy(std_header, STD_DATA_FRAME_HEADER, LONG_HEADER_SIZE); + progmem_memcpy(cmd_header, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + const bool std_prefix = memcmp(this->rx_buffer_, std_header, this->rx_len_) == 0; + const bool cmd_prefix = memcmp(this->rx_buffer_, cmd_header, this->rx_len_) == 0; + if (std_prefix || cmd_prefix) { + if (this->rx_len_ == LONG_HEADER_SIZE) { + this->frame_type_ = std_prefix ? FrameType::STD_DATA : FrameType::COMMAND; + } + return; + } + // Not a header: drop what was collected, and try this byte as the start of the next frame + const bool retry = this->rx_len_ > 1; + this->reset_frame_(); + if (retry) { + this->receive_byte_(byte); + } + return; + } + + if (this->expected_len_ == 0) { + if (this->rx_len_ < LONG_PAYLOAD_POS) { + return; + } + // Standard data and command frames carry a little-endian payload length after the header + const uint16_t payload_len = + encode_uint16(this->rx_buffer_[LONG_HEADER_SIZE + 1], this->rx_buffer_[LONG_HEADER_SIZE]); + if (payload_len > RX_BUFFER_SIZE - LONG_PAYLOAD_POS - sizeof(CMD_FRAME_FOOTER)) { + ESP_LOGV(TAG, "Dropping frame with a %u byte payload, larger than the receive buffer", payload_len); + this->count_dropped_frame_(); + this->reset_frame_(); + return; + } + this->expected_len_ = LONG_PAYLOAD_POS + payload_len + sizeof(CMD_FRAME_FOOTER); + return; + } + + if (this->rx_len_ < this->expected_len_) { + return; + } + if (this->handle_frame_() || this->rescanning_) { + this->reset_frame_(); + return; + } + // A truncated long frame swallowed the bytes that followed it; try them again as new frames, once + uint8_t rest[RX_BUFFER_SIZE]; + const uint8_t rest_len = this->rx_len_ - 1; + memcpy(rest, &this->rx_buffer_[1], rest_len); + this->reset_frame_(); + this->rescanning_ = true; + for (uint8_t i = 0; i < rest_len; i++) { + this->receive_byte_(rest[i]); + } + this->rescanning_ = false; +} + +bool LD2410SComponent::handle_frame_() { + const uint8_t *footer_src; + uint8_t footer_size = sizeof(CMD_FRAME_FOOTER); + uint8_t payload_pos = LONG_PAYLOAD_POS; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + footer_src = SHORT_DATA_FRAME_FOOTER; + footer_size = sizeof(SHORT_DATA_FRAME_FOOTER); + payload_pos = 1; + break; + case FrameType::STD_DATA: + footer_src = STD_DATA_FRAME_FOOTER; + break; + default: + footer_src = CMD_FRAME_FOOTER; + break; + } + uint8_t footer[sizeof(CMD_FRAME_FOOTER)]; + progmem_memcpy(footer, footer_src, footer_size); + if (memcmp(&this->rx_buffer_[this->rx_len_ - footer_size], footer, footer_size) != 0) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(RX_BUFFER_SIZE)]; + ESP_LOGV(TAG, "Footer does not match header: %s", + format_hex_pretty_to(hex_buf, this->rx_buffer_, this->rx_len_, ' ')); +#endif + this->count_dropped_frame_(); + return false; + } + const uint8_t *payload = &this->rx_buffer_[payload_pos]; + const uint16_t payload_len = this->rx_len_ - payload_pos - footer_size; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + case FrameType::STD_DATA: + if (!this->handle_data_frame_(payload, payload_len)) { + this->count_dropped_frame_(); + break; + } + this->last_frame_at_ = App.get_loop_component_start_time(); + this->dropped_frames_ = 0; + break; + default: + this->handle_command_ack_(payload, payload_len); + break; + } + return true; +} + +bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { + uint8_t state; + if (this->frame_type_ == FrameType::SHORT_DATA) { + // [state][distance low][distance high] + state = payload[0]; + } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { + // [type][state][distance low][distance high]...; the module only sends these before the init + // sequence switches it to minimal output + state = payload[1]; + } else { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; + } + // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to + // carry the header and footer bytes + if (state > 3) { + ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); + return false; + } + this->publish_target_(state > 1); + return true; +} + +void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) { + if (len < 4) { + ESP_LOGV(TAG, "Acknowledgement too short (%u bytes)", len); + return; + } + const uint16_t command_word = encode_uint16(payload[1], payload[0]); + const uint16_t ack = encode_uint16(payload[3], payload[2]); + if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); + return; + } + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + return; + } + this->awaiting_ack_ = false; + this->init_timeouts_ = 0; + this->init_step_++; + this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; + if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + ESP_LOGD(TAG, "Setup done"); + this->last_frame_at_ = App.get_loop_component_start_time(); + this->status_clear_warning(); + } +} + +void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_BINARY_SENSOR + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target); + } +#endif +} + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h new file mode 100644 index 0000000000..4af809836c --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::ld2410s { + +// Largest frame read here is the standard data frame: 4 header + 2 length + payload + 4 footer +static constexpr size_t RX_BUFFER_SIZE = 96; +static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); + +class LD2410SComponent final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(target) +#endif + + public: + void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence + void loop() override; + void dump_config() override; + + protected: + enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; + + void receive_byte_(uint8_t byte); + /// Returns false when the footer does not match, so the bytes can be scanned again. + bool handle_frame_(); + /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. + bool handle_data_frame_(const uint8_t *payload, uint16_t len); + void handle_command_ack_(const uint8_t *payload, uint16_t len); + void count_dropped_frame_() { + if (this->dropped_frames_ < UINT8_MAX) { + this->dropped_frames_++; + } + } + void reset_frame_() { + this->rx_len_ = 0; + this->expected_len_ = 0; + this->frame_type_ = FrameType::NONE; + } + void run_init_sequence_(uint32_t now); + void send_command_(uint16_t command); + void publish_target_(bool target); + + uint32_t next_send_at_{0}; + uint32_t last_frame_at_{0}; + uint8_t rx_buffer_[RX_BUFFER_SIZE]; + uint8_t rx_len_{0}; + uint8_t expected_len_{0}; + uint8_t init_step_{0}; + uint8_t init_timeouts_{0}; + uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 + FrameType frame_type_{FrameType::NONE}; + bool awaiting_ack_{false}; + bool rescanning_{false}; +}; + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py new file mode 100644 index 0000000000..88c3a173ef --- /dev/null +++ b/esphome/components/ld2460/__init__.py @@ -0,0 +1,47 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.types import ConfigType + +AUTO_LOAD = ["ld24xx"] +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@jesusvallejo"] +DOMAIN = "ld2460" +MULTI_CONF = True + +ld2460_ns = cg.esphome_ns.namespace("ld2460") +LD2460Component = ld2460_ns.class_("LD2460Component", cg.Component, uart.UARTDevice) + +CONF_LD2460_ID = "ld2460_id" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2460Component), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), +) + + +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) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp new file mode 100644 index 0000000000..2efd34a1d8 --- /dev/null +++ b/esphome/components/ld2460/ld2460.cpp @@ -0,0 +1,230 @@ +#include "ld2460.h" + +#include "esphome/core/hal.h" + +#include +#include +#include + +namespace esphome::ld2460 { + +ESPHOME_LOG_TAG(TAG, "ld2460"); + +static constexpr uint8_t HEADER_FOOTER_SIZE = 4; +// PROGMEM keeps the patterns out of RAM on the ESP8266; they are copied out for each compare +static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; + +static constexpr uint8_t CMD_REPORT_DATA = 0x04; +static constexpr uint8_t CMD_QUERY_INSTALLATION_MODE = 0x0A; +static constexpr uint8_t ACK_FRAME_LENGTH = 12; +static constexpr uint8_t MAX_QUERY_ATTEMPTS = 3; +// Header, command, length (12, little endian), payload 0x01, footer +static constexpr uint8_t QUERY_INSTALLATION_MODE_FRAME[] PROGMEM = { + 0xFD, 0xFC, 0xFB, 0xFA, CMD_QUERY_INSTALLATION_MODE, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01, +}; + +static bool matches(const uint8_t *bytes, const uint8_t *pattern) { + uint8_t copy[HEADER_FOOTER_SIZE]; + progmem_memcpy(copy, pattern, sizeof(copy)); + return std::memcmp(bytes, copy, sizeof(copy)) == 0; +} + +// Header (4), command (1), length (2, little endian); the length counts the whole frame +static constexpr uint8_t FRAME_PREFIX_SIZE = 7; +static uint16_t frame_length(const uint8_t *frame) { return encode_uint16(frame[6], frame[5]); } + +static constexpr float RADIANS_TO_DEGREES = 180.0f / std::numbers::pi_v; + +void LD2460Component::setup() { this->send_query_(); } + +void LD2460Component::send_query_() { + uint8_t frame[sizeof(QUERY_INSTALLATION_MODE_FRAME)]; + progmem_memcpy(frame, QUERY_INSTALLATION_MODE_FRAME, sizeof(frame)); + this->write_array(frame, sizeof(frame)); +} + +void LD2460Component::dump_config() { + ESP_LOGCONFIG(TAG, + "LD2460:\n" + " Installation mode: %s", + this->query_attempts_ != 0 ? LOG_STR_LITERAL("unknown") + : this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetCount", this->target_count_sensor_); + for (const auto &t : this->target_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetX", t.x); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetY", t.y); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetDistance", t.distance); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetAngle", t.angle); + } +#endif +} + +void LD2460Component::loop() { + size_t avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } + } +} + +void LD2460Component::handle_periodic_data_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(DATA_FRAME_HEADER) || data[4] != CMD_REPORT_DATA) { + ESP_LOGW(TAG, "Invalid periodic data header"); + return; + } + const uint8_t packet_len = this->buffer_pos_; + if (packet_len < 11 || (packet_len - 11) % 4 != 0) { + ESP_LOGW(TAG, "Periodic packet length %u is not a whole number of targets", packet_len); + return; + } + + if (this->query_attempts_ != 0) { + if (this->query_attempts_ < MAX_QUERY_ATTEMPTS) { + // The module may have booted after us and missed the earlier query + this->query_attempts_++; + this->send_query_(); + } else if (this->query_attempts_ == MAX_QUERY_ATTEMPTS) { + this->query_attempts_++; + ESP_LOGW(TAG, "No installation mode reply after %u queries; assuming side mount", MAX_QUERY_ATTEMPTS); + } + } + +#ifdef USE_SENSOR + uint8_t num_targets = std::min((packet_len - 11) / 4, MAX_TARGETS); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + TargetSensors &t = this->target_sensors_[i]; + if (i >= num_targets) { + t.last_x = INT16_MIN; + t.last_y = INT16_MIN; + SAFE_PUBLISH_SENSOR_UNKNOWN(t.x); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.y); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.distance); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.angle); + continue; + } + const uint8_t *p = &data[7 + i * 4]; + auto raw_x = static_cast(encode_uint16(p[1], p[0])); + auto raw_y = static_cast(encode_uint16(p[3], p[2])); + if (raw_x == t.last_x && raw_y == t.last_y) { + continue; + } + t.last_x = raw_x; + t.last_y = raw_y; + + // Coordinates are reported in 0.1 m steps + float x = raw_x * 0.1f; + float y = raw_y * 0.1f; + SAFE_PUBLISH_SENSOR(t.x, x); + SAFE_PUBLISH_SENSOR(t.y, y); + if (t.distance.has_sensor()) { + SAFE_PUBLISH_SENSOR(t.distance, std::sqrt(x * x + y * y)); + } + if (t.angle.has_sensor()) { + float angle; + if (this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP) { + angle = std::atan2(y, x) * RADIANS_TO_DEGREES; + if (angle < 0.0f) { + angle += 360.0f; + } + } else { + angle = std::atan2(x, y) * RADIANS_TO_DEGREES; + } + SAFE_PUBLISH_SENSOR(t.angle, angle); + } + } + SAFE_PUBLISH_SENSOR(this->target_count_sensor_, num_targets); +#endif + + this->data_callback_.call(); +} + +void LD2460Component::readline_(uint8_t readch) { + if (this->buffer_pos_ >= MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Max frame length exceeded; ignoring"); + this->buffer_pos_ = 0; + } + this->buffer_data_[this->buffer_pos_++] = readch; + + if (this->buffer_pos_ < HEADER_FOOTER_SIZE) { + return; + } + const uint8_t *tail = &this->buffer_data_[this->buffer_pos_ - HEADER_FOOTER_SIZE]; + bool is_data = readch == progmem_read_byte(&DATA_FRAME_FOOTER[3]) && matches(tail, DATA_FRAME_FOOTER); + if (!is_data && (readch != progmem_read_byte(&CMD_FRAME_FOOTER[3]) || !matches(tail, CMD_FRAME_FOOTER))) { + return; + } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; + ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); +#endif + + if (is_data) { + this->handle_periodic_data_(); + } else { + this->handle_ack_(); + } + this->buffer_pos_ = 0; +} + +// Once a footer is matched, shift the buffer to the first offset holding a header whose length +// field ends the frame exactly at the footer. Stray bytes, truncated frames and header bytes inside +// a payload are skipped this way. Returns false when no such offset exists. +bool LD2460Component::align_to_header_(const uint8_t *header) { + for (uint8_t i = 0; i + FRAME_PREFIX_SIZE <= this->buffer_pos_; i++) { + const uint8_t *frame = &this->buffer_data_[i]; + if (matches(frame, header) && frame_length(frame) == this->buffer_pos_ - i) { + if (i != 0) { + this->buffer_pos_ -= i; + std::memmove(this->buffer_data_, frame, this->buffer_pos_); + } + return true; + } + } + return false; +} + +void LD2460Component::handle_ack_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(CMD_FRAME_HEADER) || this->buffer_pos_ != ACK_FRAME_LENGTH) { + ESP_LOGW(TAG, "Dropping malformed command frame"); + return; + } + if (data[4] != CMD_QUERY_INSTALLATION_MODE) { + ESP_LOGD(TAG, "Ignoring reply to command 0x%02X", data[4]); + return; + } + const uint8_t mode = data[7]; + if (mode != static_cast(InstallationMode::INSTALLATION_MODE_SIDE) && + mode != static_cast(InstallationMode::INSTALLATION_MODE_TOP)) { + ESP_LOGW(TAG, "Unexpected installation mode %u", mode); + return; + } + this->installation_mode_ = static_cast(mode); + this->query_attempts_ = 0; + ESP_LOGD(TAG, "Installation mode: %s", + mode == static_cast(InstallationMode::INSTALLATION_MODE_TOP) ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + // Angles depend on the mode, so republish every target on the next frame + for (auto &t : this->target_sensors_) { + t.last_x = INT16_MIN; + } +#endif +} + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/ld2460.h b/esphome/components/ld2460/ld2460.h new file mode 100644 index 0000000000..aa7679f39a --- /dev/null +++ b/esphome/components/ld2460/ld2460.h @@ -0,0 +1,67 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/components/ld24xx/ld24xx.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::ld2460 { + +static constexpr uint8_t MAX_LINE_LENGTH = 32; // Longest frame is a 5-target data frame of 31 bytes +static constexpr uint8_t MAX_TARGETS = 5; + +enum class InstallationMode : uint8_t { + INSTALLATION_MODE_SIDE = 1, + INSTALLATION_MODE_TOP = 2, +}; + +class LD2460Component : public Component, public uart::UARTDevice { +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(target_count, uint8_t) +#endif + + public: + void setup() override; + void dump_config() override; + void loop() override; + +#ifdef USE_SENSOR + void set_target_x_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].x.set_sensor(s); } + void set_target_y_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].y.set_sensor(s); } + void set_target_distance_sensor(uint8_t target, sensor::Sensor *s) { + this->target_sensors_[target].distance.set_sensor(s); + } + void set_target_angle_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].angle.set_sensor(s); } +#endif + + /// Add a callback that will be called after each successfully processed periodic data frame. + template void add_on_data_callback(F &&callback) { this->data_callback_.add(std::forward(callback)); } + + protected: + void send_query_(); + bool align_to_header_(const uint8_t *header); + void handle_periodic_data_(); + void handle_ack_(); + void readline_(uint8_t readch); + +#ifdef USE_SENSOR + // INT16_MIN marks a slot with no published coordinates, so the next report always publishes + struct TargetSensors { + ld24xx::SensorWithDedup x, y, distance, angle; + int16_t last_x{INT16_MIN}; + int16_t last_y{INT16_MIN}; + }; + std::array target_sensors_{}; +#endif + + LazyCallbackManager data_callback_; + uint8_t buffer_data_[MAX_LINE_LENGTH]; + uint8_t buffer_pos_{0}; + // The angle formula depends on how the module is mounted, so it is read from the module at boot + InstallationMode installation_mode_{InstallationMode::INSTALLATION_MODE_SIDE}; + // Queries sent so far; 0 once the module answered. Data frames resend up to MAX_QUERY_ATTEMPTS. + uint8_t query_attempts_{1}; +}; + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/sensor.py b/esphome/components/ld2460/sensor.py new file mode 100644 index 0000000000..c9d4354e7a --- /dev/null +++ b/esphome/components/ld2460/sensor.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_ANGLE, + CONF_DISTANCE, + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + UNIT_DEGREES, + UNIT_METER, +) +from esphome.types import ConfigType + +from . import CONF_LD2460_ID, LD2460Component + +DEPENDENCIES = ["ld2460"] + +ICON_ACCOUNT_GROUP = "mdi:account-group" +ICON_ALPHA_X_BOX_OUTLINE = "mdi:alpha-x-box-outline" +ICON_ALPHA_Y_BOX_OUTLINE = "mdi:alpha-y-box-outline" +ICON_FORMAT_TEXT_ROTATION_ANGLE_UP = "mdi:format-text-rotation-angle-up" +ICON_MAP_MARKER_DISTANCE = "mdi:map-marker-distance" + +MAX_TARGETS = 5 + +_TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_X_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_Y_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_MAP_MARKER_DISTANCE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + ), + cv.Optional(CONF_ANGLE): sensor.sensor_schema( + icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, + ), + } +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2460_ID): cv.use_id(LD2460Component), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_ACCOUNT_GROUP, + ), + **{ + cv.Optional(f"target_{n}"): _TARGET_SCHEMA + for n in range(1, MAX_TARGETS + 1) + }, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2460_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + + setters = ( + (CONF_X, hub.set_target_x_sensor), + (CONF_Y, hub.set_target_y_sensor), + (CONF_DISTANCE, hub.set_target_distance_sensor), + (CONF_ANGLE, hub.set_target_angle_sensor), + ) + for n in range(MAX_TARGETS): + if (target_conf := config.get(f"target_{n + 1}")) is None: + continue + for key, setter in setters: + if (sens_conf := target_conf.get(key)) is not None: + cg.add(setter(n, await sensor.new_sensor(sens_conf))) diff --git a/esphome/components/light/color_mode.h b/esphome/components/light/color_mode.h index 0750ae250d..fbce5232ff 100644 --- a/esphome/components/light/color_mode.h +++ b/esphome/components/light/color_mode.h @@ -2,6 +2,7 @@ #include #include "esphome/core/finite_set_mask.h" +#include "esphome/core/hal.h" namespace esphome::light { @@ -108,8 +109,9 @@ constexpr ColorModeHelper operator|(ColorModeHelper lhs, ColorMode rhs) { using color_mode_bitmask_t = uint16_t; // Lookup table for ColorMode bit mapping -// This array defines the canonical order of color modes (bit 0-9) -constexpr ColorMode COLOR_MODE_LOOKUP[] = { +// This array defines the canonical order of color modes (bit 0-9). One shared copy in flash; read it at run time +// through color_mode_at(). +inline constexpr ColorMode COLOR_MODE_LOOKUP[] PROGMEM = { ColorMode::UNKNOWN, // bit 0 ColorMode::ON_OFF, // bit 1 ColorMode::BRIGHTNESS, // bit 2 @@ -122,6 +124,12 @@ constexpr ColorMode COLOR_MODE_LOOKUP[] = { ColorMode::RGB_COLD_WARM_WHITE, // bit 9 }; +constexpr ColorMode color_mode_at(unsigned bit) { + if (__builtin_is_constant_evaluated()) + return COLOR_MODE_LOOKUP[bit]; + return static_cast(progmem_read_byte(reinterpret_cast(&COLOR_MODE_LOOKUP[bit]))); +} + /// Bit mapping policy for ColorMode /// Uses lookup table for non-contiguous enum values struct ColorModeBitPolicy { @@ -129,17 +137,15 @@ struct ColorModeBitPolicy { static constexpr int MAX_BITS = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); static constexpr unsigned to_bit(ColorMode mode) { - // Linear search through lookup table - // Compiler optimizes this to efficient code since array is constexpr for (int i = 0; i < MAX_BITS; ++i) { - if (COLOR_MODE_LOOKUP[i] == mode) + if (color_mode_at(i) == mode) return i; } return 0; } static constexpr ColorMode from_bit(unsigned bit) { - return (bit < MAX_BITS) ? COLOR_MODE_LOOKUP[bit] : ColorMode::UNKNOWN; + return (bit < MAX_BITS) ? color_mode_at(bit) : ColorMode::UNKNOWN; } }; @@ -157,7 +163,7 @@ constexpr uint16_t compute_capability_bitmask(ColorCapability capability) { // Check each ColorMode to see if it has this capability constexpr int color_mode_count = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); for (int bit = 0; bit < color_mode_count; ++bit) { - uint8_t mode_val = static_cast(COLOR_MODE_LOOKUP[bit]); + uint8_t mode_val = static_cast(color_mode_at(bit)); if ((mode_val & cap_bit) != 0) { mask |= (1 << bit); } diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 98df98ce5d..40e9c0b1aa 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -27,7 +27,7 @@ static ProgmemStr get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root[ESPHOME_F("effect")] = state.get_effect_name().c_str(); + root[ESPHOME_F("effect")] = json::linked(state.get_effect_name()); root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); root[ESPHOME_F("effect_count")] = state.get_effect_count(); } @@ -37,7 +37,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const auto *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root[ESPHOME_F("color_mode")] = mode_str; + root[ESPHOME_F("color_mode")] = json::linked(mode_str); } if (color_mode & ColorCapability::ON_OFF) diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py new file mode 100644 index 0000000000..fcd885e5db --- /dev/null +++ b/esphome/components/lis2dw12/__init__.py @@ -0,0 +1,181 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_ID, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_OFFSET_Z, + CONF_POWER_MODE, + CONF_RANGE, + CONF_SWAP_XY, + CONF_TRANSFORM, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DOMAIN = "lis2dw12" + +CONF_LIS2DW12_ID = "lis2dw12_id" + +CONF_MIRROR_Z = "mirror_z" +CONF_ON_ACTIVE = "on_active" +CONF_ON_DOUBLE_TAP = "on_double_tap" +CONF_ON_FREEFALL = "on_freefall" +CONF_ON_ORIENTATION = "on_orientation" +CONF_ON_TAP = "on_tap" + +CONF_LOW_NOISE = "low_noise" +CONF_FILTER_BANDWIDTH = "filter_bandwidth" +CONF_OUTPUT_DATA_RATE = "output_data_rate" + +lis2dw12_ns = cg.esphome_ns.namespace("lis2dw12") +LIS2DW12Component = lis2dw12_ns.class_("LIS2DW12Component", cg.PollingComponent) + +LIS2DW12Range = lis2dw12_ns.enum("Range", True) +RANGES = { + "2G": LIS2DW12Range.RANGE_2G, + "4G": LIS2DW12Range.RANGE_4G, + "8G": LIS2DW12Range.RANGE_8G, + "16G": LIS2DW12Range.RANGE_16G, +} + +LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) +POWER_MODES = { + "high_performance": LIS2DW12PowerMode.HIGH_PERF, + "low_power_1": LIS2DW12PowerMode.LOW_POWER_1, + "low_power_2": LIS2DW12PowerMode.LOW_POWER_2, + "low_power_3": LIS2DW12PowerMode.LOW_POWER_3, + "low_power_4": LIS2DW12PowerMode.LOW_POWER_4, +} + +LIS2DW12DataRate = lis2dw12_ns.enum("DataRate", True) +DATA_RATES = { + "1.6Hz": LIS2DW12DataRate.RATE_1_6HZ, + "12.5Hz": LIS2DW12DataRate.RATE_12_5HZ, + "25Hz": LIS2DW12DataRate.RATE_25HZ, + "50Hz": LIS2DW12DataRate.RATE_50HZ, + "100Hz": LIS2DW12DataRate.RATE_100HZ, + "200Hz": LIS2DW12DataRate.RATE_200HZ, + "400Hz": LIS2DW12DataRate.RATE_400HZ, + "800Hz": LIS2DW12DataRate.RATE_800HZ, + "1600Hz": LIS2DW12DataRate.RATE_1600HZ, +} + +LIS2DW12FilterBandwidth = lis2dw12_ns.enum("FilterBandwidth", True) +FILTER_BANDWIDTHS = { + "odr_div_2": LIS2DW12FilterBandwidth.BW_ODR_DIV_2, + "odr_div_4": LIS2DW12FilterBandwidth.BW_ODR_DIV_4, + "odr_div_10": LIS2DW12FilterBandwidth.BW_ODR_DIV_10, + "odr_div_20": LIS2DW12FilterBandwidth.BW_ODR_DIV_20, +} + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), + automation.CallbackAutomation(CONF_ON_DOUBLE_TAP, "add_on_double_tap_callback"), + automation.CallbackAutomation(CONF_ON_FREEFALL, "add_on_freefall_callback"), + automation.CallbackAutomation(CONF_ON_ACTIVE, "add_on_active_callback"), + automation.CallbackAutomation(CONF_ON_ORIENTATION, "add_on_orientation_callback"), +) + +_OFFSET = cv.float_range(min=-4.5, max=4.5) + +# The ODR code means different rates per power mode: 0001 is 1.6 Hz only in low-power modes +# (12.5 Hz in high performance) and every code above 200 Hz runs at 200 Hz in low-power modes +_LOW_POWER_ONLY_RATES = ("1.6Hz",) +_HIGH_PERF_ONLY_RATES = ("400Hz", "800Hz", "1600Hz") + + +def validate_data_rate(config: ConfigType) -> ConfigType: + """Reject rates the chip cannot run in the chosen power mode; apply after extending the base schema.""" + rate = str(config[CONF_OUTPUT_DATA_RATE]) + mode = str(config[CONF_POWER_MODE]) + high_perf = mode == "high_performance" + if (high_perf and rate in _LOW_POWER_ONLY_RATES) or ( + not high_perf and rate in _HIGH_PERF_ONLY_RATES + ): + raise cv.Invalid( + f"{CONF_OUTPUT_DATA_RATE} {rate} is not available with {CONF_POWER_MODE} {mode}", + [CONF_OUTPUT_DATA_RATE], + ) + return config + + +CONFIG_SCHEMA_BASE = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LIS2DW12Component), + cv.Optional(CONF_RANGE, default="2G"): cv.enum(RANGES, upper=True), + cv.Optional(CONF_POWER_MODE, default="high_performance"): cv.enum( + POWER_MODES, lower=True + ), + cv.Optional(CONF_OUTPUT_DATA_RATE, default="100Hz"): cv.enum(DATA_RATES), + cv.Optional(CONF_FILTER_BANDWIDTH, default="odr_div_2"): cv.enum( + FILTER_BANDWIDTHS, lower=True + ), + cv.Optional(CONF_LOW_NOISE, default=False): cv.boolean, + cv.Optional(CONF_CALIBRATION): cv.Schema( + { + cv.Optional(CONF_OFFSET_X, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Y, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Z, default=0): _OFFSET, + } + ), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Z, default=False): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional(CONF_ON_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_DOUBLE_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_FREEFALL): automation.validate_automation({}), + cv.Optional(CONF_ON_ACTIVE): automation.validate_automation({}), + cv.Optional(CONF_ON_ORIENTATION): automation.validate_automation({}), + } +).extend(cv.polling_component_schema("10s")) + +LIS2DW12_SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LIS2DW12_ID): cv.use_id(LIS2DW12Component), + } +) + + +async def to_code_base(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_range(config[CONF_RANGE])) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + cg.add(var.set_output_data_rate(config[CONF_OUTPUT_DATA_RATE])) + cg.add(var.set_filter_bandwidth(config[CONF_FILTER_BANDWIDTH])) + cg.add(var.set_low_noise(config[CONF_LOW_NOISE])) + + if transform := config.get(CONF_TRANSFORM): + cg.add( + var.set_transform( + transform[CONF_MIRROR_X], + transform[CONF_MIRROR_Y], + transform[CONF_MIRROR_Z], + transform[CONF_SWAP_XY], + ) + ) + + if calibration_config := config.get(CONF_CALIBRATION): + cg.add( + var.set_offset( + calibration_config[CONF_OFFSET_X], + calibration_config[CONF_OFFSET_Y], + calibration_config[CONF_OFFSET_Z], + ) + ) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + return var diff --git a/esphome/components/lis2dw12/binary_sensor.py b/esphome/components/lis2dw12/binary_sensor.py new file mode 100644 index 0000000000..3ff0e8f15c --- /dev/null +++ b/esphome/components/lis2dw12/binary_sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_DOUBLE_TAP = "double_tap" +CONF_FREEFALL = "freefall" +CONF_TAP = "tap" + +ICON_DOUBLE_TAP = "mdi:gesture-double-tap" +ICON_FREEFALL = "mdi:parachute" +ICON_TAP = "mdi:gesture-tap" + +EVENT_SENSORS = (CONF_TAP, CONF_DOUBLE_TAP, CONF_FREEFALL, CONF_ACTIVE) +ICONS = (ICON_TAP, ICON_DOUBLE_TAP, ICON_FREEFALL, ICON_VIBRATE) + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(event): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_VIBRATION, + icon=icon, + ), + key=CONF_NAME, + ) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_TAP, hub.set_tap_binary_sensor) + await sensors(CONF_DOUBLE_TAP, hub.set_double_tap_binary_sensor) + await sensors(CONF_FREEFALL, hub.set_freefall_binary_sensor) + await sensors(CONF_ACTIVE, hub.set_active_binary_sensor) diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp new file mode 100644 index 0000000000..38065aa785 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -0,0 +1,350 @@ +#include "lis2dw12.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12 { + +ESPHOME_LOG_TAG(TAG, "lis2dw12"); + +static constexpr uint8_t LIS2DW12_WHO_AM_I_VALUE = 0x44; +static constexpr float GRAVITY_EARTH = 9.80665f; +static constexpr uint32_t EVENT_COOLDOWN_MS = 500; +static constexpr uint32_t POLL_INTERVAL_MS = 50; +static constexpr uint8_t SIXD_POSITION_MASK = 0x3F; // XL..ZH, leaving out D6D_IA + +static const LogString *power_mode_to_string(PowerMode mode) { + switch (mode) { + case PowerMode::HIGH_PERF: + return LOG_STR("High Performance"); + case PowerMode::LOW_POWER_1: + return LOG_STR("Low Power 1 (12-bit)"); + case PowerMode::LOW_POWER_2: + return LOG_STR("Low Power 2"); + case PowerMode::LOW_POWER_3: + return LOG_STR("Low Power 3"); + case PowerMode::LOW_POWER_4: + return LOG_STR("Low Power 4"); + } + return LOG_STR("Unknown"); +} + +static const LogString *range_to_string(Range range) { + switch (range) { + case Range::RANGE_2G: + return LOG_STR("±2g"); + case Range::RANGE_4G: + return LOG_STR("±4g"); + case Range::RANGE_8G: + return LOG_STR("±8g"); + case Range::RANGE_16G: + return LOG_STR("±16g"); + } + return LOG_STR("Unknown"); +} + +static const LogString *data_rate_to_string(DataRate rate) { + switch (rate) { + case DataRate::RATE_1_6HZ: + return LOG_STR("1.6 Hz"); + case DataRate::RATE_12_5HZ: + return LOG_STR("12.5 Hz"); + case DataRate::RATE_25HZ: + return LOG_STR("25 Hz"); + case DataRate::RATE_50HZ: + return LOG_STR("50 Hz"); + case DataRate::RATE_100HZ: + return LOG_STR("100 Hz"); + case DataRate::RATE_200HZ: + return LOG_STR("200 Hz"); + case DataRate::RATE_400HZ: + return LOG_STR("400 Hz"); + case DataRate::RATE_800HZ: + return LOG_STR("800 Hz"); + case DataRate::RATE_1600HZ: + return LOG_STR("1600 Hz"); + } + return LOG_STR("Unknown"); +} + +static const LogString *filter_bandwidth_to_string(FilterBandwidth bw) { + switch (bw) { + case FilterBandwidth::BW_ODR_DIV_2: + return LOG_STR("ODR/2"); + case FilterBandwidth::BW_ODR_DIV_4: + return LOG_STR("ODR/4"); + case FilterBandwidth::BW_ODR_DIV_10: + return LOG_STR("ODR/10"); + case FilterBandwidth::BW_ODR_DIV_20: + return LOG_STR("ODR/20"); + } + return LOG_STR("Unknown"); +} + +void LIS2DW12Component::setup() { + uint8_t who_am_i{0}; + if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i) || who_am_i != LIS2DW12_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "WHO_AM_I check failed. Got 0x%02X, expected 0x%02X", who_am_i, LIS2DW12_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // Soft reset + if (!this->write_reg_(RegisterMap::CTRL2, 0x40)) { + this->mark_failed(LOG_STR("Soft reset failed")); + return; + } + delay(1); // NOLINT - wait for reset to complete + + uint8_t ctrl2{0}; + if (!this->read_byte(static_cast(RegisterMap::CTRL2), &ctrl2) || (ctrl2 & 0x40)) { + this->mark_failed(LOG_STR("Soft reset did not complete")); + return; + } + + if (!this->configure_registers_()) { + this->mark_failed(LOG_STR("Register configuration failed")); + return; + } + + // 0.244 mg/LSB at ±2g (14-bit), doubling per range step; 12-bit LP1 is 4x coarser + const bool lp1 = this->power_mode_ == PowerMode::LOW_POWER_1; + const uint8_t range_idx = static_cast(this->range_); + const float mg = 0.244f * (1u << range_idx) * (lp1 ? 4 : 1); + this->scale_ = mg * 0.001f * GRAVITY_EARTH; + this->shift_ = lp1 ? 4 : 2; + + bool events_used = !this->tap_callback_.empty() || !this->double_tap_callback_.empty() || + !this->freefall_callback_.empty() || !this->active_callback_.empty() || + !this->orientation_callback_.empty(); +#ifdef USE_BINARY_SENSOR + for (auto *sensor : this->event_binary_sensors_) { + if (sensor != nullptr) { + sensor->publish_initial_state(false); + events_used = true; + } + } +#endif +#ifdef USE_TEXT_SENSOR + RegSixdSrc sixd; + if (this->orientation_text_sensor_ != nullptr && + this->read_byte(static_cast(RegisterMap::SIXD_SRC), &sixd.raw)) { + this->publish_orientation_(sixd); + events_used = true; + } +#endif + if (!events_used) { + this->disable_loop(); // nothing consumes the events, so skip polling the chip + } +} + +bool LIS2DW12Component::configure_registers_() { + // CTRL1: ODR[7:4], MODE[3:2] (00=low power, 01=high performance), LP_MODE[1:0] (LP1..LP4) + const uint8_t odr_bits = static_cast(this->data_rate_) << 4; + uint8_t ctrl1; + if (this->power_mode_ == PowerMode::HIGH_PERF) { + ctrl1 = odr_bits | 0x04; + } else { + ctrl1 = odr_bits | ((static_cast(this->power_mode_) - 1) & 0x03); + } + + // CTRL6: BW_FILT[7:6], FS[5:4], LOW_NOISE[2] + uint8_t ctrl6 = (static_cast(this->filter_bandwidth_) << 6) | (static_cast(this->range_) << 4); + if (this->low_noise_) { + ctrl6 |= 0x04; + } + + return this->write_reg_(RegisterMap::CTRL2, 0x0C) && // BDU, IF_ADD_INC + this->write_reg_(RegisterMap::CTRL1, ctrl1) && // ODR and power mode + this->write_reg_(RegisterMap::CTRL6, ctrl6) && // bandwidth, range, low noise + this->write_reg_(RegisterMap::TAP_THS_X, 0x29) && // 6D_THS=70 degrees, TAP_THSX=9 + this->write_reg_(RegisterMap::TAP_THS_Y, 0x09) && // TAP_THSY=9 + this->write_reg_(RegisterMap::TAP_THS_Z, 0xE9) && // TAP_THSZ=9, tap on X/Y/Z + this->write_reg_(RegisterMap::INT_DUR, 0x76) && // LATENCY=7, QUIET=1, SHOCK=2 + this->write_reg_(RegisterMap::WAKE_UP_THS, 0x82) && // double tap enabled, WU_THS=2 + this->write_reg_(RegisterMap::WAKE_UP_DUR, 0x00) && // no wake/sleep durations + this->write_reg_(RegisterMap::FREE_FALL, 0x33) && // FF_DUR=6, FF_THS=312 mg + this->write_reg_(RegisterMap::CTRL4_INT1, 0xF8) && // 6D, single tap, wake-up, free-fall, double tap to INT1 + this->write_reg_(RegisterMap::CTRL3, 0x10) && // LIR: latched interrupts + this->write_reg_(RegisterMap::CTRL7, 0x20); // INTERRUPTS_ENABLE +} + +void LIS2DW12Component::dump_config() { + ESP_LOGCONFIG(TAG, "LIS2DW12:"); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + ESP_LOGCONFIG(TAG, + " Power Mode: %s\n" + " Data Rate: %s\n" + " Range: %s\n" + " Filter Bandwidth: %s\n" + " Low Noise: %s\n" + " Offsets: {%.3f m/s², %.3f m/s², %.3f m/s²}\n" + " Transform: {mirror_x=%s, mirror_y=%s, mirror_z=%s, swap_xy=%s}", + LOG_STR_ARG(power_mode_to_string(this->power_mode_)), + LOG_STR_ARG(data_rate_to_string(this->data_rate_)), LOG_STR_ARG(range_to_string(this->range_)), + LOG_STR_ARG(filter_bandwidth_to_string(this->filter_bandwidth_)), YESNO(this->low_noise_), + this->offset_x_, this->offset_y_, this->offset_z_, YESNO(this->mirror_x_), YESNO(this->mirror_y_), + YESNO(this->mirror_z_), YESNO(this->swap_xy_)); + LOG_UPDATE_INTERVAL(this); + +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Tap", this->event_binary_sensors_[EVENT_TAP]); + LOG_BINARY_SENSOR(" ", "Double Tap", this->event_binary_sensors_[EVENT_DOUBLE_TAP]); + LOG_BINARY_SENSOR(" ", "Freefall", this->event_binary_sensors_[EVENT_FREEFALL]); + LOG_BINARY_SENSOR(" ", "Active", this->event_binary_sensors_[EVENT_ACTIVE]); +#endif + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Acceleration X", this->acceleration_x_sensor_); + LOG_SENSOR(" ", "Acceleration Y", this->acceleration_y_sensor_); + LOG_SENSOR(" ", "Acceleration Z", this->acceleration_z_sensor_); +#endif + +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Orientation", this->orientation_text_sensor_); +#endif +} + +bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { + uint8_t accel_data[6]; + if (!this->read_bytes(static_cast(RegisterMap::OUT_X_L), accel_data, 6)) { + return false; + } + + int16_t raw_x = static_cast(encode_uint16(accel_data[1], accel_data[0])) >> this->shift_; + int16_t raw_y = static_cast(encode_uint16(accel_data[3], accel_data[2])) >> this->shift_; + int16_t raw_z = static_cast(encode_uint16(accel_data[5], accel_data[4])) >> this->shift_; + + if (this->swap_xy_) { + std::swap(raw_x, raw_y); + } + x = (this->mirror_x_ ? -raw_x : raw_x) * this->scale_ + this->offset_x_; + y = (this->mirror_y_ ? -raw_y : raw_y) * this->scale_ + this->offset_y_; + z = (this->mirror_z_ ? -raw_z : raw_z) * this->scale_ + this->offset_z_; + return true; +} + +#ifdef USE_TEXT_SENSOR +static const char *orientation_to_string(RegSixdSrc sixd) { + if (sixd.xh) + return "X Up"; + if (sixd.xl) + return "X Down"; + if (sixd.yh) + return "Y Up"; + if (sixd.yl) + return "Y Down"; + if (sixd.zh) + return "Z Up"; + if (sixd.zl) + return "Z Down"; + return "Unknown"; +} + +void LIS2DW12Component::publish_orientation_(RegSixdSrc sixd) { + const uint8_t position = sixd.raw & SIXD_POSITION_MASK; + if (this->orientation_text_sensor_ == nullptr || position == this->sixd_last_) { + return; + } + this->sixd_last_ = position; + this->orientation_text_sensor_->publish_state(orientation_to_string(sixd)); +} +#endif + +void LIS2DW12Component::handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback) { + const uint8_t bit = 1 << event; + bool state; + if (active) { + if (now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->last_event_ms_[event] = now; + callback.call(); + if (this->events_on_ & bit) { + return; + } + this->events_on_ |= bit; + state = true; + } else { + if (!(this->events_on_ & bit) || now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->events_on_ &= ~bit; + state = false; + } +#ifdef USE_BINARY_SENSOR + if (this->event_binary_sensors_[event] != nullptr) { + this->event_binary_sensors_[event]->publish_state(state); + } +#endif +} + +void LIS2DW12Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ms_ < POLL_INTERVAL_MS) { + return; + } + this->last_poll_ms_ = now; + + // SIXD_SRC and ALL_INT_SRC are adjacent, so one read captures both before any latch clears + uint8_t regs[2]; + if (!this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs))) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + RegSixdSrc sixd; + sixd.raw = regs[0]; + RegAllIntSrc src; + src.raw = regs[1]; + + this->handle_event_(EVENT_TAP, src.single_tap, now, this->tap_callback_); + this->handle_event_(EVENT_DOUBLE_TAP, src.double_tap, now, this->double_tap_callback_); + this->handle_event_(EVENT_FREEFALL, src.ff_ia, now, this->freefall_callback_); + this->handle_event_(EVENT_ACTIVE, src.wu_ia, now, this->active_callback_); + + if (sixd.d6d_ia) { + this->orientation_callback_.call(); +#ifdef USE_TEXT_SENSOR + this->publish_orientation_(sixd); +#endif + } +} + +void LIS2DW12Component::update() { + float x, y, z; + if (!this->read_acceleration_data_(x, y, z)) { + this->status_set_warning(); + return; + } + + ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); + +#ifdef USE_SENSOR + if (this->acceleration_x_sensor_ != nullptr) + this->acceleration_x_sensor_->publish_state(x); + if (this->acceleration_y_sensor_ != nullptr) + this->acceleration_y_sensor_->publish_state(y); + if (this->acceleration_z_sensor_ != nullptr) + this->acceleration_z_sensor_->publish_state(z); +#endif + + this->status_clear_warning(); +} + +void LIS2DW12Component::set_offset(float offset_x, float offset_y, float offset_z) { + this->offset_x_ = offset_x; + this->offset_y_ = offset_y; + this->offset_z_ = offset_z; +} + +void LIS2DW12Component::set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy) { + this->mirror_x_ = mirror_x; + this->mirror_y_ = mirror_y; + this->mirror_z_ = mirror_z; + this->swap_xy_ = swap_xy; +} + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/lis2dw12.h b/esphome/components/lis2dw12/lis2dw12.h new file mode 100644 index 0000000000..11c1e6b862 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.h @@ -0,0 +1,198 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome::lis2dw12 { + +// LIS2DW12 Register Map +enum class RegisterMap : uint8_t { + WHO_AM_I = 0x0F, + CTRL1 = 0x20, + CTRL2 = 0x21, + CTRL3 = 0x22, + CTRL4_INT1 = 0x23, + CTRL6 = 0x25, + OUT_X_L = 0x28, + TAP_THS_X = 0x30, + TAP_THS_Y = 0x31, + TAP_THS_Z = 0x32, + INT_DUR = 0x33, + WAKE_UP_THS = 0x34, + WAKE_UP_DUR = 0x35, + FREE_FALL = 0x36, + SIXD_SRC = 0x3A, + ALL_INT_SRC = 0x3B, + CTRL7 = 0x3F, +}; + +enum class Range : uint8_t { + RANGE_2G = 0b00, + RANGE_4G = 0b01, + RANGE_8G = 0b10, + RANGE_16G = 0b11, +}; + +enum class PowerMode : uint8_t { + HIGH_PERF = 0, + LOW_POWER_1 = 1, + LOW_POWER_2 = 2, + LOW_POWER_3 = 3, + LOW_POWER_4 = 4, +}; + +enum class DataRate : uint8_t { + RATE_1_6HZ = 0b0001, + RATE_12_5HZ = 0b0010, + RATE_25HZ = 0b0011, + RATE_50HZ = 0b0100, + RATE_100HZ = 0b0101, + RATE_200HZ = 0b0110, + RATE_400HZ = 0b0111, + RATE_800HZ = 0b1000, + RATE_1600HZ = 0b1001, +}; + +enum class FilterBandwidth : uint8_t { + BW_ODR_DIV_2 = 0b00, + BW_ODR_DIV_4 = 0b01, + BW_ODR_DIV_10 = 0b10, + BW_ODR_DIV_20 = 0b11, +}; + +// ALL_INT_SRC register (0x3B) +union RegAllIntSrc { + struct { + bool ff_ia : 1; + bool wu_ia : 1; + bool single_tap : 1; + bool double_tap : 1; + bool d6d_ia : 1; + bool sleep_change_ia : 1; + uint8_t reserved : 2; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// SIXD_SRC register (0x3A) +union RegSixdSrc { + struct { + bool xl : 1; + bool xh : 1; + bool yl : 1; + bool yh : 1; + bool zl : 1; + bool zh : 1; + bool d6d_ia : 1; + uint8_t reserved : 1; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// Debounced events, also the bit index into events_on_ +enum Event : uint8_t { + EVENT_TAP = 0, + EVENT_DOUBLE_TAP, + EVENT_FREEFALL, + EVENT_ACTIVE, + EVENT_COUNT, +}; + +class LIS2DW12Component : public PollingComponent { + public: + void setup() override; + void dump_config() override; + void loop() override; + void update() override; + + void set_power_mode(PowerMode mode) { this->power_mode_ = mode; } + void set_low_noise(bool low_noise) { this->low_noise_ = low_noise; } + void set_output_data_rate(DataRate rate) { this->data_rate_ = rate; } + void set_range(Range range) { this->range_ = range; } + void set_filter_bandwidth(FilterBandwidth bw) { this->filter_bandwidth_ = bw; } + void set_offset(float offset_x, float offset_y, float offset_z); + void set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy); + + bool read_byte(uint8_t a_register, uint8_t *data) { return this->read_bytes(a_register, data, 1); } + virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; + virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + +#ifdef USE_BINARY_SENSOR + void set_tap_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_TAP] = s; } + void set_double_tap_binary_sensor(binary_sensor::BinarySensor *s) { + this->event_binary_sensors_[EVENT_DOUBLE_TAP] = s; + } + void set_freefall_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_FREEFALL] = s; } + void set_active_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_ACTIVE] = s; } +#endif + +#ifdef USE_SENSOR + SUB_SENSOR(acceleration_x) + SUB_SENSOR(acceleration_y) + SUB_SENSOR(acceleration_z) +#endif + +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(orientation) +#endif + + template void add_on_tap_callback(F &&callback) { this->tap_callback_.add(std::forward(callback)); } + template void add_on_double_tap_callback(F &&callback) { + this->double_tap_callback_.add(std::forward(callback)); + } + template void add_on_freefall_callback(F &&callback) { + this->freefall_callback_.add(std::forward(callback)); + } + template void add_on_active_callback(F &&callback) { + this->active_callback_.add(std::forward(callback)); + } + template void add_on_orientation_callback(F &&callback) { + this->orientation_callback_.add(std::forward(callback)); + } + + protected: + bool write_reg_(RegisterMap reg, uint8_t value) { return this->write_byte(static_cast(reg), value); } + bool configure_registers_(); + bool read_acceleration_data_(float &x, float &y, float &z); + void handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback); +#ifdef USE_TEXT_SENSOR + void publish_orientation_(RegSixdSrc sixd); +#endif + + float offset_x_{0}, offset_y_{0}, offset_z_{0}; + float scale_{0}; // m/s² per LSB after shift_ + uint32_t last_event_ms_[EVENT_COUNT]{}; + uint32_t last_poll_ms_{0}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *event_binary_sensors_[EVENT_COUNT]{}; +#endif + LazyCallbackManager tap_callback_; + LazyCallbackManager double_tap_callback_; + LazyCallbackManager freefall_callback_; + LazyCallbackManager active_callback_; + LazyCallbackManager orientation_callback_; + + PowerMode power_mode_{PowerMode::HIGH_PERF}; + DataRate data_rate_{DataRate::RATE_100HZ}; + Range range_{Range::RANGE_2G}; + FilterBandwidth filter_bandwidth_{FilterBandwidth::BW_ODR_DIV_2}; + bool low_noise_{false}; + bool mirror_x_{false}, mirror_y_{false}, mirror_z_{false}, swap_xy_{false}; + uint8_t shift_{2}; // data is left-justified: 14-bit >> 2, 12-bit (LP1) >> 4 + uint8_t events_on_{0}; // bit per Event: binary sensor currently published on +#ifdef USE_TEXT_SENSOR + uint8_t sixd_last_{0xFF}; // position bits only; 0xFF cannot occur, so the first read publishes +#endif +}; + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/sensor.py b/esphome/components/lis2dw12/sensor.py new file mode 100644 index 0000000000..9f4a4e570f --- /dev/null +++ b/esphome/components/lis2dw12/sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCELERATION_X, + CONF_ACCELERATION_Y, + CONF_ACCELERATION_Z, + CONF_NAME, + ICON_ACCELERATION, + STATE_CLASS_MEASUREMENT, + UNIT_METER_PER_SECOND_SQUARED, +) +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +ACCELERATION_SENSORS = (CONF_ACCELERATION_X, CONF_ACCELERATION_Y, CONF_ACCELERATION_Z) + +accel_schema = cv.maybe_simple_value( + sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND_SQUARED, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + key=CONF_NAME, +) + + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + {cv.Optional(key): accel_schema for key in ACCELERATION_SENSORS} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACCELERATION_X, hub.set_acceleration_x_sensor) + await sensors(CONF_ACCELERATION_Y, hub.set_acceleration_y_sensor) + await sensors(CONF_ACCELERATION_Z, hub.set_acceleration_z_sensor) diff --git a/esphome/components/lis2dw12/text_sensor.py b/esphome/components/lis2dw12/text_sensor.py new file mode 100644 index 0000000000..b292cc87cc --- /dev/null +++ b/esphome/components/lis2dw12/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, ICON_SCREEN_ROTATION +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_ORIENTATION = "orientation" + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(CONF_ORIENTATION): cv.maybe_simple_value( + text_sensor.text_sensor_schema(icon=ICON_SCREEN_ROTATION), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_ORIENTATION, hub.set_orientation_text_sensor) diff --git a/esphome/components/lis2dw12_i2c/__init__.py b/esphome/components/lis2dw12_i2c/__init__.py new file mode 100644 index 0000000000..39cbd46a8d --- /dev/null +++ b/esphome/components/lis2dw12_i2c/__init__.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lis2dw12_i2c" +MULTI_CONF = True + +lis2dw12_i2c_ns = cg.esphome_ns.namespace("lis2dw12_i2c") +LIS2DW12I2CComponent = lis2dw12_i2c_ns.class_( + "LIS2DW12I2CComponent", LIS2DW12Component, i2c.I2CDevice +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12I2CComponent), + } + ).extend(i2c.i2c_device_schema(0x19)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp new file mode 100644 index 0000000000..3956f0a42d --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp @@ -0,0 +1,13 @@ +#include "lis2dw12_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_i2c { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_i2c"); + +void LIS2DW12I2CComponent::dump_config() { + LOG_I2C_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h new file mode 100644 index 0000000000..aa6069e434 --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/lis2dw12/lis2dw12.h" + +namespace esphome::lis2dw12_i2c { + +class LIS2DW12I2CComponent : public lis2dw12::LIS2DW12Component, public i2c::I2CDevice { + public: + bool write_byte(uint8_t a_register, uint8_t data) override { return i2c::I2CDevice::write_byte(a_register, data); } + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + return i2c::I2CDevice::read_bytes(a_register, data, len); + } + void dump_config() override; +}; + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_spi/__init__.py b/esphome/components/lis2dw12_spi/__init__.py new file mode 100644 index 0000000000..1b0815d691 --- /dev/null +++ b/esphome/components/lis2dw12_spi/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["spi"] +DOMAIN = "lis2dw12_spi" +MULTI_CONF = True + +lis2dw12_spi_ns = cg.esphome_ns.namespace("lis2dw12_spi") +LIS2DW12SPIComponent = lis2dw12_spi_ns.class_( + "LIS2DW12SPIComponent", + LIS2DW12Component, + spi.SPIDevice, +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12SPIComponent), + } + ).extend(spi.spi_device_schema(cs_pin_required=True)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp new file mode 100644 index 0000000000..0ca013ef6c --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp @@ -0,0 +1,34 @@ +#include "lis2dw12_spi.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_spi { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_spi"); + +void LIS2DW12SPIComponent::setup() { + this->spi_setup(); + lis2dw12::LIS2DW12Component::setup(); +} + +void LIS2DW12SPIComponent::dump_config() { + LOG_SPI_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +bool LIS2DW12SPIComponent::write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & 0x3F); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool LIS2DW12SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | 0x80); + this->read_array(data, len); + this->disable(); + return true; +} + +} // namespace esphome::lis2dw12_spi diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.h b/esphome/components/lis2dw12_spi/lis2dw12_spi.h new file mode 100644 index 0000000000..0b9b3a2e54 --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/lis2dw12/lis2dw12.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::lis2dw12_spi { + +class LIS2DW12SPIComponent : public lis2dw12::LIS2DW12Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + bool write_byte(uint8_t a_register, uint8_t data) override; + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; +}; + +} // namespace esphome::lis2dw12_spi diff --git a/esphome/components/lis3dh/__init__.py b/esphome/components/lis3dh/__init__.py new file mode 100644 index 0000000000..99c4ee9187 --- /dev/null +++ b/esphome/components/lis3dh/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "lis3dh" + +CONF_LIS3DH_ID = "lis3dh_id" + +# C++ namespace / class +lis3dh_ns = cg.esphome_ns.namespace("lis3dh") +LIS3DHComponent = lis3dh_ns.class_("LIS3DHComponent", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp new file mode 100644 index 0000000000..22ca7ae56b --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -0,0 +1,195 @@ +#include "lis3dh.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis3dh { + +ESPHOME_LOG_TAG(TAG, "lis3dh"); + +// g per LSB of the left-justified 16-bit sample, indexed by range. The same in every +// operating mode, since the chip leaves the unused low bits at zero. +static const float SCALE_G[] PROGMEM = {0.0625e-3f, 0.125e-3f, 0.25e-3f, 0.75e-3f}; + +static const LogString *mode_to_str(LIS3DHOperatingMode mode) { + switch (mode) { + case LIS3DH_MODE_LOW_POWER: + return LOG_STR("low power (8-bit)"); + case LIS3DH_MODE_NORMAL: + return LOG_STR("normal (10-bit)"); + default: + return LOG_STR("high resolution (12-bit)"); + } +} + +// Output data rate in Hz, indexed by the ODR field minus one. +static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1344}; + +void LIS3DHComponent::setup() { + MotionComponent::setup(); + + uint8_t who_am_i = 0; + if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { + ESP_LOGE(TAG, "Failed to configure sensor"); + this->mark_failed(); + return; + } +} + +bool LIS3DHComponent::setup_accelerometer_() { + // CTRL_REG1: output data rate, low-power flag and axis enable. + uint8_t ctrl_reg1 = ((uint8_t) this->data_rate_ << 4) | LIS3DH_CTRL_REG1_AXES_EN; + if (this->operating_mode_ == LIS3DH_MODE_LOW_POWER) + ctrl_reg1 |= LIS3DH_CTRL_REG1_LPEN; + if (!this->write_byte(LIS3DH_REG_CTRL_REG1, ctrl_reg1)) + return false; + + // CTRL_REG4: block data update, full-scale range and high-resolution flag. + uint8_t ctrl_reg4 = LIS3DH_CTRL_REG4_BDU | ((uint8_t) this->range_ << 4); + if (this->operating_mode_ == LIS3DH_MODE_HIGH_RESOLUTION) + ctrl_reg4 |= LIS3DH_CTRL_REG4_HR; + return this->write_byte(LIS3DH_REG_CTRL_REG4, ctrl_reg4); +} + +bool LIS3DHComponent::setup_temperature_() { + // Only power the ADC / temperature sensor when a temperature sensor is used. + uint8_t temp_cfg = + this->temperature_callback_.empty() ? 0x00 : (uint8_t) (LIS3DH_TEMP_CFG_ADC_EN | LIS3DH_TEMP_CFG_TEMP_EN); + return this->write_byte(LIS3DH_REG_TEMP_CFG, temp_cfg); +} + +bool LIS3DHComponent::setup_interrupt_() { + // Runs even with no interrupt configured: the chip usually stays powered across resets, + // so this drops whatever the previous firmware left in the interrupt registers. + // High-pass filter the interrupt (IA1) input so the ~1 g of gravity is removed + // from the threshold comparison. The output data registers stay unfiltered + // (FDS = 0), so acceleration / pitch / roll still report the full gravity vector. + uint8_t ctrl_reg2 = this->interrupt_high_pass_ ? LIS3DH_CTRL_REG2_HP_IA1 : 0x00; + if (!this->write_byte(LIS3DH_REG_CTRL_REG2, ctrl_reg2)) + return false; + + if (!this->write_byte(LIS3DH_REG_INT1_CFG, this->interrupt_axes_cfg_) || + !this->write_byte(LIS3DH_REG_INT1_THS, this->interrupt_threshold_) || + !this->write_byte(LIS3DH_REG_INT1_DURATION, this->interrupt_duration_)) + return false; + + // Latch the request so a brief movement keeps the pad asserted until INT1_SRC is read. + uint8_t ctrl_reg5 = this->interrupt_latched_ ? LIS3DH_CTRL_REG5_LIR_INT1 : 0; + if (!this->write_byte(LIS3DH_REG_CTRL_REG5, ctrl_reg5)) + return false; + + // Route the IA1 engine to the chosen physical pad and set the pad polarity. + uint8_t ctrl_reg3 = 0; + uint8_t ctrl_reg6 = this->interrupt_active_high_ ? 0 : LIS3DH_CTRL_REG6_INT_POLARITY; + if (this->interrupt_axes_cfg_ != 0) { + if (this->interrupt_pin_ == LIS3DH_INT_PIN_INT1) { + ctrl_reg3 |= LIS3DH_CTRL_REG3_I1_IA1; + } else { + ctrl_reg6 |= LIS3DH_CTRL_REG6_I2_IA1; + } + } + if (!this->write_byte(LIS3DH_REG_CTRL_REG3, ctrl_reg3) || !this->write_byte(LIS3DH_REG_CTRL_REG6, ctrl_reg6)) + return false; + if (this->interrupt_axes_cfg_ == 0) + return true; + + // With the high-pass filter enabled, reading REFERENCE captures the current + // acceleration as the filter's zero point (removing the standing gravity bias). + if (this->interrupt_high_pass_) { + uint8_t reference = 0; + this->read_byte(LIS3DH_REG_REFERENCE, &reference); + } + + // Clear any latched request left over from the motion that woke the device. + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + return true; +} + +void LIS3DHComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LIS3DH:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, + " Range: ±%ug\n" + " Operating mode: %s\n" + " Data rate: %uHz", + 2u << this->range_, LOG_STR_ARG(mode_to_str(this->operating_mode_)), + (unsigned) progmem_read_uint16(&RATE_HZ[this->data_rate_ - 1])); + if (this->interrupt_axes_cfg_ != 0) { + ESP_LOGCONFIG(TAG, + " Motion interrupt: %s (%s%s%s)\n" + " Threshold: %u steps\n" + " Duration: %u samples", + this->interrupt_pin_ == LIS3DH_INT_PIN_INT1 ? LOG_STR_LITERAL("INT1") : LOG_STR_LITERAL("INT2"), + this->interrupt_active_high_ ? LOG_STR_LITERAL("active high") : LOG_STR_LITERAL("active low"), + this->interrupt_high_pass_ ? LOG_STR_LITERAL(", high-pass") : LOG_STR_LITERAL(""), + this->interrupt_latched_ ? LOG_STR_LITERAL(", latched") : LOG_STR_LITERAL(""), + this->interrupt_threshold_, this->interrupt_duration_); + } + MotionComponent::dump_config(); +} + +void LIS3DHComponent::on_shutdown() { + // Clear a latched request from motion seen while awake, so it does not wake the device straight away. + if (this->interrupt_latched_ && this->interrupt_axes_cfg_ != 0 && !this->is_failed()) { + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + } +} + +bool LIS3DHComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + uint8_t raw[6]; + if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + this->status_set_warning(LOG_STR("Failed to read acceleration data")); + return false; + } + + float scale; + progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); + + // Data is little-endian and left-justified in a signed 16-bit register. + int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); + int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); + int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty() || this->publish_temperature_()) + this->status_clear_warning(); + return true; +} + +bool LIS3DHComponent::publish_temperature_() { + uint8_t raw[2]; + if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + this->status_set_warning(LOG_STR("Failed to read temperature data")); + return false; + } + // Signed, left-justified, relative value with 1 °C per count of the high byte; the + // low byte carries the fractional bits in the modes that have them. + float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/lis3dh.h b/esphome/components/lis3dh/lis3dh.h new file mode 100644 index 0000000000..15c19933c7 --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.h @@ -0,0 +1,135 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::lis3dh { + +// Register map (see ST datasheet DocID18198) +static constexpr uint8_t LIS3DH_REG_OUT_ADC3_L = 0x0C; // temperature (ADC3) low byte +static constexpr uint8_t LIS3DH_REG_WHO_AM_I = 0x0F; +static constexpr uint8_t LIS3DH_REG_TEMP_CFG = 0x1F; +static constexpr uint8_t LIS3DH_REG_CTRL_REG1 = 0x20; // ODR / low-power / axis enable +static constexpr uint8_t LIS3DH_REG_CTRL_REG2 = 0x21; // high-pass filter +static constexpr uint8_t LIS3DH_REG_CTRL_REG3 = 0x22; // INT1 pad routing +static constexpr uint8_t LIS3DH_REG_CTRL_REG4 = 0x23; // BDU / full-scale / high-resolution +static constexpr uint8_t LIS3DH_REG_CTRL_REG5 = 0x24; // interrupt latching +static constexpr uint8_t LIS3DH_REG_CTRL_REG6 = 0x25; // INT2 pad routing / polarity +static constexpr uint8_t LIS3DH_REG_REFERENCE = 0x26; // high-pass filter reference +static constexpr uint8_t LIS3DH_REG_OUT_X_L = 0x28; // acceleration data block (auto-increment) +static constexpr uint8_t LIS3DH_REG_INT1_CFG = 0x30; +static constexpr uint8_t LIS3DH_REG_INT1_SRC = 0x31; +static constexpr uint8_t LIS3DH_REG_INT1_THS = 0x32; +static constexpr uint8_t LIS3DH_REG_INT1_DURATION = 0x33; + +static constexpr uint8_t LIS3DH_WHO_AM_I_VALUE = 0x33; + +// Sub-address auto-increment bit: OR into the register for multi-byte reads. +static constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80; + +// CTRL_REG1 +static constexpr uint8_t LIS3DH_CTRL_REG1_AXES_EN = 0x07; // Xen | Yen | Zen +static constexpr uint8_t LIS3DH_CTRL_REG1_LPEN = 0x08; // low-power enable +// CTRL_REG2 +static constexpr uint8_t LIS3DH_CTRL_REG2_HP_IA1 = 0x01; // high-pass filter the IA1 interrupt +// CTRL_REG4 +static constexpr uint8_t LIS3DH_CTRL_REG4_HR = 0x08; // high-resolution enable +static constexpr uint8_t LIS3DH_CTRL_REG4_BDU = 0x80; // block data update +// CTRL_REG5 +static constexpr uint8_t LIS3DH_CTRL_REG5_LIR_INT1 = 0x08; // latch IA1 interrupt +// CTRL_REG3 / CTRL_REG6 interrupt routing +static constexpr uint8_t LIS3DH_CTRL_REG3_I1_IA1 = 0x40; // route IA1 to INT1 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_I2_IA1 = 0x40; // route IA1 to INT2 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_INT_POLARITY = 0x02; // 1 = active low +// TEMP_CFG_REG +static constexpr uint8_t LIS3DH_TEMP_CFG_ADC_EN = 0x80; +static constexpr uint8_t LIS3DH_TEMP_CFG_TEMP_EN = 0x40; + +// The temperature sensor is uncalibrated and reports relative changes only, +// so this fixed reference is just a rough starting point for the °C output. +static constexpr float LIS3DH_TEMP_REFERENCE_C = 25.0f; + +// Full-scale range: value is the index into the scale tables and the +// FS field of CTRL_REG4 (shifted left by 4). +enum LIS3DHRange : uint8_t { + LIS3DH_RANGE_2G = 0, + LIS3DH_RANGE_4G = 1, + LIS3DH_RANGE_8G = 2, + LIS3DH_RANGE_16G = 3, +}; + +// Output data rate: value is the ODR field of CTRL_REG1 (bits 7:4). +enum LIS3DHDataRate : uint8_t { + LIS3DH_DATA_RATE_1HZ = 0x1, + LIS3DH_DATA_RATE_10HZ = 0x2, + LIS3DH_DATA_RATE_25HZ = 0x3, + LIS3DH_DATA_RATE_50HZ = 0x4, + LIS3DH_DATA_RATE_100HZ = 0x5, + LIS3DH_DATA_RATE_200HZ = 0x6, + LIS3DH_DATA_RATE_400HZ = 0x7, + LIS3DH_DATA_RATE_1620HZ = 0x8, // low-power mode only + LIS3DH_DATA_RATE_1344HZ = 0x9, // 1344 Hz normal/HR, 5376 Hz low-power +}; + +// Operating mode: selects the resolution (and thus power usage). +enum LIS3DHOperatingMode : uint8_t { + LIS3DH_MODE_LOW_POWER = 0, // 8-bit + LIS3DH_MODE_NORMAL = 1, // 10-bit + LIS3DH_MODE_HIGH_RESOLUTION = 2, // 12-bit +}; + +// Physical interrupt pad the motion interrupt is routed to. +enum LIS3DHInterruptPin : uint8_t { + LIS3DH_INT_PIN_INT1 = 0, + LIS3DH_INT_PIN_INT2 = 1, +}; + +class LIS3DHComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + + void set_range(LIS3DHRange range) { this->range_ = range; } + void set_data_rate(LIS3DHDataRate data_rate) { this->data_rate_ = data_rate; } + void set_operating_mode(LIS3DHOperatingMode mode) { this->operating_mode_ = mode; } + // threshold is the INT1_THS value and axes_cfg the INT1_CFG mask, both computed in Python. + void set_interrupt(LIS3DHInterruptPin pin, uint8_t threshold, uint8_t duration, uint8_t axes_cfg, bool latched, + bool active_high, bool high_pass) { + this->interrupt_pin_ = pin; + this->interrupt_threshold_ = threshold; + this->interrupt_duration_ = duration; + this->interrupt_axes_cfg_ = axes_cfg; + this->interrupt_latched_ = latched; + this->interrupt_active_high_ = active_high; + this->interrupt_high_pass_ = high_pass; + } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool setup_accelerometer_(); + bool setup_temperature_(); + bool setup_interrupt_(); + bool publish_temperature_(); + + LIS3DHRange range_{LIS3DH_RANGE_2G}; + LIS3DHDataRate data_rate_{LIS3DH_DATA_RATE_100HZ}; + LIS3DHOperatingMode operating_mode_{LIS3DH_MODE_HIGH_RESOLUTION}; + + // Motion (activity) interrupt configuration; a zero axis mask means disabled. + LIS3DHInterruptPin interrupt_pin_{LIS3DH_INT_PIN_INT1}; + uint8_t interrupt_threshold_{0}; + uint8_t interrupt_duration_{0}; + uint8_t interrupt_axes_cfg_{0}; + bool interrupt_latched_{false}; + bool interrupt_active_high_{true}; + bool interrupt_high_pass_{false}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py new file mode 100644 index 0000000000..ea4b4d3b81 --- /dev/null +++ b/esphome/components/lis3dh/motion.py @@ -0,0 +1,214 @@ +import logging +import math + +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE +from esphome.components.motion import AXES, motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType + +from . import LIS3DHComponent, lis3dh_ns + +_LOGGER = logging.getLogger(__name__) + +CONF_OPERATING_MODE = "operating_mode" +CONF_AXES = "axes" +CONF_LATCHED = "latched" +CONF_ACTIVE_HIGH = "active_high" +CONF_HIGH_PASS_FILTER = "high_pass_filter" + +# Enum values must match the C++ enums. +LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") +RANGE_OPTIONS = { + "2G": LIS3DHRange.LIS3DH_RANGE_2G, + "4G": LIS3DHRange.LIS3DH_RANGE_4G, + "8G": LIS3DHRange.LIS3DH_RANGE_8G, + "16G": LIS3DHRange.LIS3DH_RANGE_16G, +} + +LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") +DATA_RATE_OPTIONS = { + "1HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1HZ, + "10HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_10HZ, + "25HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_25HZ, + "50HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_50HZ, + "100HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_100HZ, + "200HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_200HZ, + "400HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_400HZ, + # 1620HZ is only available in low-power mode. + "1620HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1620HZ, + # 1344HZ in normal / high-resolution mode; 5376HZ in low-power mode. + "1344HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1344HZ, +} + +# Samples per second for each data rate key; INT1_DURATION counts samples. +ODR_HZ = { + "1HZ": 1, + "10HZ": 10, + "25HZ": 25, + "50HZ": 50, + "100HZ": 100, + "200HZ": 200, + "400HZ": 400, + "1620HZ": 1620, + "1344HZ": 1344, +} +DURATION_MAX_REG = 0x7F + +LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") +OPERATING_MODE_OPTIONS = { + "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, + "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, + "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, +} + +LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") +INTERRUPT_PIN_OPTIONS = { + "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, + "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, +} + +# INT1_CFG high-event bit per axis. +INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} + +# Motion interrupt threshold LSB in mg, per range. INT1_THS holds seven bits. +THRESHOLD_LSB_MG = {"2G": 16, "4G": 32, "8G": 62, "16G": 186} +THRESHOLD_MAX_REG = 0x7F + + +def _threshold_register(threshold_g: float, range_key: str) -> int: + """Return INT1_THS for a threshold in g: nearest step, at least 1 so the pad is not held asserted.""" + return max(math.floor(threshold_g * 1000 / THRESHOLD_LSB_MG[range_key] + 0.5), 1) + + +def _duration_register(duration_ms: int, odr_key: str) -> int: + """Return INT1_DURATION for a time in ms: the nearest number of samples at the data rate.""" + return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5) + + +def _axes(value: object) -> list[str]: + value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( + value + ) + if len(set(value)) != len(value): + raise cv.Invalid("Each axis may be listed only once") + return value + + +# Without high_pass_filter the threshold includes gravity, so it should exceed 1g. +INTERRUPT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PIN, default="INT1"): cv.enum( + INTERRUPT_PIN_OPTIONS, upper=True + ), + cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All( + cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float + ), + cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AXES, default=AXES): _axes, + cv.Optional(CONF_LATCHED, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean, + cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean, + } +) + + +def _validate_odr_mode(config: ConfigType) -> ConfigType: + # 1620Hz exists only in low power mode; the 1344Hz code runs at 5376Hz there. + odr = config[CONF_ACCELEROMETER_ODR] + low_power = config[CONF_OPERATING_MODE] == "LOW_POWER" + if odr == "1620HZ" and not low_power: + raise cv.Invalid( + "'1620HZ' is only available with 'operating_mode: LOW_POWER'", + path=[CONF_ACCELEROMETER_ODR], + ) + if odr == "1344HZ" and low_power: + raise cv.Invalid( + "'1344HZ' is not available with 'operating_mode: LOW_POWER' " + "(that combination runs at 5376Hz)", + path=[CONF_ACCELEROMETER_ODR], + ) + return config + + +def _validate_interrupt(config: ConfigType) -> ConfigType: + if (interrupt := config.get(CONF_INTERRUPT)) is None: + return config + range_key = config[CONF_ACCELEROMETER_RANGE] + threshold = interrupt[CONF_THRESHOLD] + max_g = THRESHOLD_MAX_REG * THRESHOLD_LSB_MG[range_key] / 1000 + if threshold > max_g: + raise cv.Invalid( + f"'threshold' can be at most {max_g:g}g with 'accelerometer_range: {range_key}'", + path=[CONF_INTERRUPT, CONF_THRESHOLD], + ) + if not interrupt[CONF_HIGH_PASS_FILTER] and threshold <= 1.0: + _LOGGER.warning( + "lis3dh: a threshold of %sg or less includes gravity and may keep the interrupt " + "asserted at rest; set 'high_pass_filter: true' to compare against motion only", + threshold, + ) + odr_key = config[CONF_ACCELEROMETER_ODR] + duration_ms = interrupt[CONF_DURATION].total_milliseconds + if _duration_register(duration_ms, odr_key) > DURATION_MAX_REG: + max_ms = math.floor(DURATION_MAX_REG * 1000 / ODR_HZ[odr_key]) + raise cv.Invalid( + f"'duration' can be at most {max_ms}ms with 'accelerometer_odr: {odr_key}'", + path=[CONF_INTERRUPT, CONF_DURATION], + ) + return config + + +CONFIG_SCHEMA = cv.All( + motion_schema(LIS3DHComponent, has_accel=True, has_gyro=False) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + DATA_RATE_OPTIONS, upper=True + ), + cv.Optional(CONF_OPERATING_MODE, default="HIGH_RESOLUTION"): cv.enum( + OPERATING_MODE_OPTIONS, upper=True + ), + cv.Optional(CONF_INTERRUPT): INTERRUPT_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x18)), + _validate_odr_mode, + _validate_interrupt, +) + + +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_data_rate(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_operating_mode(config[CONF_OPERATING_MODE])) + + if (interrupt := config.get(CONF_INTERRUPT)) is not None: + threshold = _threshold_register( + interrupt[CONF_THRESHOLD], config[CONF_ACCELEROMETER_RANGE] + ) + axes_cfg = 0 + for axis in interrupt[CONF_AXES]: + axes_cfg |= INT_CFG_HIGH_EVENT[axis] + cg.add( + var.set_interrupt( + interrupt[CONF_PIN], + threshold, + _duration_register( + interrupt[CONF_DURATION].total_milliseconds, + config[CONF_ACCELEROMETER_ODR], + ), + axes_cfg, + interrupt[CONF_LATCHED], + interrupt[CONF_ACTIVE_HIGH], + interrupt[CONF_HIGH_PASS_FILTER], + ) + ) diff --git a/esphome/components/lis3dh/sensor.py b/esphome/components/lis3dh/sensor.py new file mode 100644 index 0000000000..15d19bcc0c --- /dev/null +++ b/esphome/components/lis3dh/sensor.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_LIS3DH_ID, LIS3DHComponent + +# The LIS3DH temperature sensor is uncalibrated and only reports relative +# changes (roughly 1 °C per count), so a single decimal of accuracy is plenty. +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_LIS3DH_ID): cv.use_id(LIS3DHComponent), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LIS3DH_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py new file mode 100644 index 0000000000..b8ace97fc9 --- /dev/null +++ b/esphome/components/lp5562/__init__.py @@ -0,0 +1,29 @@ +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 = ["@DennisGaida"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lp5562" +MULTI_CONF = True + +lp5562_ns = cg.esphome_ns.namespace("lp5562") +LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LP5562Output), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +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) diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp new file mode 100644 index 0000000000..356ef2c066 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -0,0 +1,72 @@ +#include "lp5562_output.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lp5562 { + +ESPHOME_LOG_TAG(TAG, "lp5562"); + +// see table 26 in the datasheet https://web.archive.org/web/20240327023921/https://www.ti.com/lit/ds/symlink/lp5562.pdf +static constexpr uint8_t LP5562_REG_ENABLE = 0x00; +static constexpr uint8_t LP5562_REG_CONFIG = 0x08; +static constexpr uint8_t LP5562_REG_RESET = 0x0d; +static constexpr uint8_t LP5562_REG_LED_MAP = 0x70; + +static constexpr uint8_t LP5562_RESET_VALUE = 0xff; + +static constexpr uint8_t LP5562_ENABLE_CHIP_EN = 0x40; +static constexpr uint8_t LP5562_CONFIG_INT_CLK_EN = 0x01; +static constexpr uint8_t LP5562_LED_MAP_ALL_PWM_REGISTERS = 0x00; // every channel driven by its PWM register + +// PWM duty-cycle registers, indexed the same way as the LP5562_CHANNELS enum in output.py +static const uint8_t LP5562_REG_PWM[4] PROGMEM = { + 0x02, // blue + 0x03, // green + 0x04, // red + 0x0e, // white +}; + +void LP5562Output::setup() { + // clear any state left over from before a warm reboot; the chip may not ACK while resetting, so ignore the result + this->write_byte(LP5562_REG_RESET, LP5562_RESET_VALUE); + delay(1); + // power up the chip and enable the internal 32 kHz oscillator, see + // https://github.com/m5stack/M5GFX/blob/b2422cdb4735159209c2097d425c0756505b9a81/src/M5GFX.cpp#L566 + if (!this->write_byte(LP5562_REG_ENABLE, LP5562_ENABLE_CHIP_EN)) { + this->mark_failed(); + return; + } + delay(1); + // use the internal clock, and drive every channel directly from its PWM register instead of the + // internal blink/fade engines + if (!this->write_byte(LP5562_REG_CONFIG, LP5562_CONFIG_INT_CLK_EN) || + !this->write_byte(LP5562_REG_LED_MAP, LP5562_LED_MAP_ALL_PWM_REGISTERS)) { + this->mark_failed(); + } +} + +void LP5562Output::dump_config() { + ESP_LOGCONFIG(TAG, "LP5562:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, "Setting up LP5562 failed!"); + } +} + +void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { + if (this->is_failed()) + return; + if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); +} + +void LP5562Channel::write_state(float state) { + const uint8_t max_duty = 255; + const auto duty = static_cast(roundf(state * max_duty)); + this->parent_->set_channel_value_(this->channel_, duty); +} + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h new file mode 100644 index 0000000000..a67a4b0658 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::lp5562 { + +class LP5562Output; + +class LP5562Channel final : public output::FloatOutput, public Parented { + public: + void set_channel(uint8_t channel) { this->channel_ = channel; } + + protected: + void write_state(float state) override; + + uint8_t channel_{0}; +}; + +/// LP5562 4-channel (RGBW) I2C LED driver. +class LP5562Output final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + friend class LP5562Channel; + + void set_channel_value_(uint8_t channel, uint8_t value); +}; + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py new file mode 100644 index 0000000000..4866eb7b9c --- /dev/null +++ b/esphome/components/lp5562/output.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType + +from . import LP5562Output, lp5562_ns + +DEPENDENCIES = ["lp5562"] + +LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) +CONF_LP5562_ID = "lp5562_id" + +# Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp +LP5562_CHANNELS = { + "blue": 0, + "green": 1, + "red": 2, + "white": 3, +} + +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(LP5562Channel), + cv.GenerateID(CONF_LP5562_ID): cv.use_id(LP5562Output), + cv.Required(CONF_CHANNEL): cv.enum(LP5562_CHANNELS, lower=True), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_LP5562_ID]) + cg.add(var.set_channel(config[CONF_CHANNEL])) + await output.register_output(var, config) diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index bab413f04d..f1c3887d5c 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -15,11 +15,10 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObj, TemplateArguments -from ..const import CONF_LOOP +from ..const import CONF_LOOP, CONF_ON_STOP from .defines import ( CONF_AUTO_START, CONF_LVGL_ID, - CONF_ON_STOP, CONF_WIDGETS, LValidator, add_define, diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h new file mode 100644 index 0000000000..e2392694f0 --- /dev/null +++ b/esphome/components/lvgl/color_picker.h @@ -0,0 +1,625 @@ +#pragma once + +#include "lvgl_esphome.h" +#include "esphome/core/color.h" + +#include +#include +#include +#include +#include + +#ifdef USE_LVGL_COLOR_PICKER + +namespace esphome::lvgl { + +// The hue gradients need seven stops; gradient.py raises the limit when a colour picker is in use. +static_assert(LV_GRADIENT_MAX_STOPS >= 7, "color_picker needs LV_GRADIENT_MAX_STOPS >= 7"); + +class LvColorPickerType : public LvCompound { + public: + enum SliderIndex : size_t { + SLIDER_HUE, + SLIDER_SATURATION, + SLIDER_BRIGHTNESS, + SLIDER_RED, + SLIDER_GREEN, + SLIDER_BLUE, + SLIDER_COUNT, + }; + + enum SliderFlag : uint8_t { + SLIDER_FLAG_HUE = 1 << SLIDER_HUE, + SLIDER_FLAG_SATURATION = 1 << SLIDER_SATURATION, + SLIDER_FLAG_BRIGHTNESS = 1 << SLIDER_BRIGHTNESS, + SLIDER_FLAG_RED = 1 << SLIDER_RED, + SLIDER_FLAG_GREEN = 1 << SLIDER_GREEN, + SLIDER_FLAG_BLUE = 1 << SLIDER_BLUE, + }; + + explicit LvColorPickerType(uint8_t sliders, bool use_wheel) : sliders_mask_(sliders), use_wheel_(use_wheel) {} + + // Returns null for a slider the widget was not built with. + lv_obj_t *get_slider(size_t index) const { return this->sliders_[index]; } + + void set_tint_knobs(bool tint_knobs) { this->tint_knobs_ = tint_knobs; } + + void set_color(lv_color_t color) { + lv_color32_t c32 = lv_color_to_32(color, LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + if (this->obj != nullptr) + this->update_color_(); + } + Color get_color() const { return this->state_; } + + void set_obj(lv_obj_t *outer) override { + LvCompound::set_obj(outer); + init_shared(); + // The far end of these two follows the current hue. + init_bar(&this->saturation_bar_, LV_GRAD_DIR_HOR, lv_color_white(), lv_color_hex(0xFF0000)); + init_bar(&this->brightness_bar_, LV_GRAD_DIR_HOR, lv_color_black(), lv_color_white()); + + auto channels = static_cast(this->enabled_(SLIDER_RED)) + + static_cast(this->enabled_(SLIDER_GREEN)) + + static_cast(this->enabled_(SLIDER_BLUE)); + + // No layout on the outer object, so LV_SIZE_CONTENT uses the size from self_size_cb. + lv_obj_add_event_cb(outer, self_size_cb, LV_EVENT_GET_SELF_SIZE, nullptr); + lv_obj_add_event_cb(outer, size_changed_cb, LV_EVENT_SIZE_CHANGED, this); + lv_obj_set_style_pad_all(outer, KNOB_OVERHANG, LV_PART_MAIN); + + int32_t left_width = 0; + lv_obj_t *left = nullptr; + if (this->enabled_(SLIDER_HUE)) { + left_width = HUE_WIDTH_PCT; + left = this->create_slider_(outer, SLIDER_HUE, "Hue", true, &hue_bar, 360); + lv_obj_set_size(left, lv_pct(HUE_WIDTH_PCT), lv_pct(100)); + } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + else if (this->use_wheel_) { + left_width = WHEEL_WIDTH_PCT; + left = this->create_wheel_(outer); + lv_obj_set_size(left, lv_pct(WHEEL_WIDTH_PCT), lv_pct(WHEEL_WIDTH_PCT)); + } +#endif + if (left != nullptr) { + lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN); + // A narrow hue column may be slimmer than its label. + lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + } + + // The text rows keep their natural height and the bars between them take what is left, + // so a small widget gets thinner bars rather than clipped text. + auto *middle = create_flex(outer, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START); + lv_obj_set_style_align(middle, LV_ALIGN_RIGHT_MID, LV_PART_MAIN); + lv_obj_set_style_pad_row(middle, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_size(middle, lv_pct(100 - left_width), lv_pct(100)); + + // A dark bar needs light text, unlike every other value label. + if (this->enabled_(SLIDER_BRIGHTNESS)) + this->create_row_slider_(middle, SLIDER_BRIGHTNESS, "Brightness", &this->brightness_bar_, lv_color_white()); + + auto *inner = create_flex(middle, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_START); + lv_obj_set_width(inner, lv_pct(100)); + lv_obj_set_flex_grow(inner, 1); + + // The channel bars keep a fixed width each, so dropping one widens the swatch. + auto channels_width = CHANNEL_WIDTH_PCT * channels; + auto *indicator_container = create_flex(inner, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + lv_obj_set_size(indicator_container, lv_pct(CONTENT_WIDTH_PCT - channels_width), lv_pct(100)); + lv_obj_set_style_pad_ver(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + + this->color_text_ = lv_label_create(indicator_container); + this->color_indicator_ = lv_obj_create(indicator_container); + lv_obj_set_style_border_color(this->color_indicator_, lv_color_hex(0x808080), LV_PART_MAIN); + lv_obj_set_style_border_width(this->color_indicator_, 1, LV_PART_MAIN); + lv_obj_set_style_radius(this->color_indicator_, 0, LV_PART_MAIN); + lv_obj_set_width(this->color_indicator_, lv_pct(90)); + lv_obj_set_flex_grow(this->color_indicator_, 1); + + if (channels != 0) { + auto *rgb_container = create_flex(inner, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_SPACE_BETWEEN); + lv_obj_set_size(rgb_container, lv_pct(channels_width), lv_pct(100)); + struct ChannelInfo { + SliderIndex index; + const char *name; + const lv_grad_dsc_t *grad; + }; + const std::array channel_info{{ + {SLIDER_RED, "R", &red_bar}, + {SLIDER_GREEN, "G", &green_bar}, + {SLIDER_BLUE, "B", &blue_bar}, + }}; + for (const auto &channel : channel_info) { + if (!this->enabled_(channel.index)) + continue; + auto *container = this->create_slider_(rgb_container, channel.index, channel.name, true, channel.grad, 255); + lv_obj_set_size(container, lv_pct(100 / channels), lv_pct(100)); + this->add_value_label_(channel.index, lv_color_black()); + } + } + + if (this->enabled_(SLIDER_SATURATION)) + this->create_row_slider_(middle, SLIDER_SATURATION, "Saturation", &this->saturation_bar_, lv_color_black()); + + this->update_color_(); + + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->sliders_[index] == nullptr) + continue; + lv_obj_add_event_cb(this->sliders_[index], index <= SLIDER_BRIGHTNESS ? hsv_changed_cb : rgb_changed_cb, + LV_EVENT_VALUE_CHANGED, this); + } + + bubble_events(outer); + } + + protected: + // LVGL keeps only pointers to gradients and styles, so one copy serves every colour picker. + static void init_shared() { + if (shared_ready) + return; + shared_ready = true; + + // Clockwise from red at 3 o'clock, matching how lv_color_hsv_to_rgb() reads the angle. + std::array hues{lv_color_hex(0xFF0000), lv_color_hex(0xFFFF00), lv_color_hex(0x00FF00), + lv_color_hex(0x00FFFF), lv_color_hex(0x0000FF), lv_color_hex(0xFF00FF), + lv_color_hex(0xFF0000)}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + lv_grad_conical_init(&wheel_hue_grad, lv_pct(50), lv_pct(50), 0, 360, LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_hue_grad, hues.data(), nullptr, nullptr, static_cast(hues.size())); + // White at the centre fading out at the rim, giving saturation over the hue wheel. + const lv_color_t whites[] = {lv_color_white(), lv_color_white()}; + const lv_opa_t white_opas[] = {LV_OPA_COVER, LV_OPA_TRANSP}; + lv_grad_radial_init(&wheel_white_grad, lv_pct(50), lv_pct(50), lv_pct(100), lv_pct(50), LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_white_grad, whites, white_opas, nullptr, 2); +#endif + // A vertical slider has its minimum at the bottom, so the hue bar runs the other way. + std::reverse(hues.begin(), hues.end()); + lv_grad_vertical_init(&hue_bar); + lv_grad_init_stops(&hue_bar, hues.data(), nullptr, nullptr, static_cast(hues.size())); + + init_bar(&red_bar, LV_GRAD_DIR_VER, lv_color_hex(0xFF0000), lv_color_white()); + init_bar(&green_bar, LV_GRAD_DIR_VER, lv_color_hex(0x00FF00), lv_color_white()); + init_bar(&blue_bar, LV_GRAD_DIR_VER, lv_color_hex(0x0000FF), lv_color_white()); + + lv_style_init(&plain_box); + lv_style_set_bg_opa(&plain_box, LV_OPA_TRANSP); + lv_style_set_border_width(&plain_box, 0); + lv_style_set_pad_all(&plain_box, 0); + lv_style_set_pad_row(&plain_box, 0); + lv_style_set_pad_column(&plain_box, 0); + + init_slider_styles(&slider_vert, &slider_vert_knob, true); + init_slider_styles(&slider_horz, &slider_horz_knob, false); + } + + static void init_bar(lv_grad_dsc_t *grad, lv_grad_dir_t dir, lv_color_t from, lv_color_t to) { + const lv_color_t colors[] = {from, to}; + grad->dir = dir; + lv_grad_init_stops(grad, colors, nullptr, nullptr, 2); + } + + static void init_slider_styles(lv_style_t *bar, lv_style_t *knob, bool vertical) { + lv_style_init(bar); + lv_style_set_align(bar, LV_ALIGN_CENTER); + lv_style_set_bg_opa(bar, LV_OPA_COVER); + lv_style_set_pad_all(bar, 0); + lv_style_set_radius(bar, 0); + // A horizontal bar's thickness is set by update_bar_thickness_(). + if (vertical) { + lv_style_set_width(bar, lv_pct(50)); + lv_style_set_height(bar, lv_pct(90)); + } else { + lv_style_set_width(bar, lv_pct(90)); + } + + lv_style_init(knob); + lv_style_set_bg_color(knob, lv_color_white()); + lv_style_set_border_width(knob, 1); + lv_style_set_outline_color(knob, lv_color_black()); + lv_style_set_radius(knob, 0); + // Both axes are set, or the theme picks a knob size the layout has not left room for. + int32_t along = -KNOB_TRIM; + int32_t across = KNOB_OVERHANG; + lv_style_set_pad_top(knob, vertical ? along : across); + lv_style_set_pad_bottom(knob, vertical ? along : across); + lv_style_set_pad_left(knob, vertical ? across : along); + lv_style_set_pad_right(knob, vertical ? across : along); + } + + // A borderless, unpadded container. + static lv_obj_t *create_box(lv_obj_t *parent) { + auto *obj = lv_obj_create(parent); + lv_obj_add_style(obj, &plain_box, LV_PART_MAIN); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + return obj; + } + + static lv_obj_t *create_flex(lv_obj_t *parent, lv_flex_flow_t flow, lv_flex_align_t main_place, + lv_flex_align_t track_place = LV_FLEX_ALIGN_START) { + auto *obj = create_box(parent); + lv_obj_set_layout(obj, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(obj, flow); + lv_obj_set_flex_align(obj, main_place, LV_FLEX_ALIGN_CENTER, track_place); + return obj; + } + + // Preferred size, in lines of the text font. The widget is square. + static constexpr int SELF_SIZE_FONT_MULTIPLE = 16; + + // At either end of its travel half the knob lies outside the bar. KNOB_TRIM pulls it back + // along the bar, and KNOB_OVERHANG makes it stand out across the bar. + static constexpr int KNOB_TRIM = 4; + static constexpr int KNOB_OVERHANG = 4; + + // Layout proportions, as percentages. + static constexpr int32_t HUE_WIDTH_PCT = 15; + static constexpr int32_t CHANNEL_WIDTH_PCT = 19; + static constexpr int32_t CONTENT_WIDTH_PCT = 99; + static constexpr int32_t WHEEL_WIDTH_PCT = 45; + static constexpr int32_t WHEEL_HANDLE_SIZE = 14; + + bool enabled_(SliderIndex index) const { return (this->sliders_mask_ & (1 << index)) != 0; } + bool has_(SliderIndex index) const { return this->sliders_[index] != nullptr; } + bool can_change_saturation_() const { + return this->use_wheel_ || (this->sliders_mask_ & + (SLIDER_FLAG_SATURATION | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != 0; + } + bool can_change_brightness_() const { + return (this->sliders_mask_ & (SLIDER_FLAG_BRIGHTNESS | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != + 0; + } + + // A slider the widget was built without keeps the value held internally. + int32_t slider_value_(SliderIndex index, int32_t fallback) const { + return this->has_(index) ? lv_slider_get_value(this->sliders_[index]) : fallback; + } + + void set_slider_value_(SliderIndex index, int32_t value) { + if (this->has_(index)) + lv_slider_set_value(this->sliders_[index], value, LV_ANIM_OFF); + } + + // Builds a slider with a name label above it, and returns its container. + lv_obj_t *create_slider_(lv_obj_t *parent, SliderIndex index, const char *name, bool vertical, + const lv_grad_dsc_t *grad, int32_t max) { + // A column keeps the label on its own row however large the font is. + auto *container = create_flex(parent, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + if (vertical) { + // How far the knob reaches past the ends depends on the font, so follow style changes. + update_knob_clearance(container); + lv_obj_add_event_cb(container, knob_clearance_cb, LV_EVENT_STYLE_CHANGED, nullptr); + } else { + lv_obj_set_style_pad_bottom(container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, KNOB_OVERHANG, LV_PART_MAIN); + } + lv_label_set_text(lv_label_create(container), name); + + auto *slider = lv_slider_create(container); + this->sliders_[index] = slider; + lv_obj_add_style(slider, vertical ? &slider_vert : &slider_horz, LV_PART_MAIN); + lv_obj_set_style_bg_grad(slider, grad, LV_PART_MAIN); + lv_obj_set_style_bg_opa(slider, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_add_style(slider, vertical ? &slider_vert_knob : &slider_horz_knob, LV_PART_KNOB); + if (vertical) + lv_obj_set_flex_grow(slider, 1); + lv_slider_set_range(slider, 0, max); + lv_slider_set_mode(slider, LV_SLIDER_MODE_NORMAL); + return container; + } + + void create_row_slider_(lv_obj_t *parent, SliderIndex index, const char *name, const lv_grad_dsc_t *grad, + lv_color_t text_color) { + auto *container = this->create_slider_(parent, index, name, false, grad, 100); + lv_obj_set_size(container, lv_pct(100), LV_SIZE_CONTENT); + this->add_value_label_(index, text_color); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // A full-size circle filled with a gradient. + static lv_obj_t *create_disc(lv_obj_t *parent, const lv_grad_dsc_t *grad) { + auto *disc = create_box(parent); + lv_obj_set_size(disc, lv_pct(100), lv_pct(100)); + lv_obj_set_style_align(disc, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN); + lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE); + return disc; + } + + // Builds the colour wheel that replaces the hue and saturation sliders, and returns its container. + lv_obj_t *create_wheel_(lv_obj_t *parent) { + auto *container = create_box(parent); + this->wheel_ = create_disc(container, &wheel_hue_grad); + // Not clickable, so touches fall through to the wheel underneath. + auto *overlay = create_disc(this->wheel_, &wheel_white_grad); + lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + + this->wheel_handle_ = lv_obj_create(this->wheel_); + lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); + lv_obj_set_style_align(this->wheel_handle_, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(this->wheel_handle_, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN); + lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE); + + // PRESSED as well, so a tap takes effect without the finger moving. + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this); + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSING, this); + // The handle can only be placed once the wheel has been laid out. + lv_obj_add_event_cb(this->wheel_, wheel_size_cb, LV_EVENT_SIZE_CHANGED, this); + return container; + } +#endif + + void add_value_label_(SliderIndex index, lv_color_t color) { + auto *slider = this->sliders_[index]; + lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + auto *label = lv_label_create(slider); + this->values_[index] = label; + lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(label, color, LV_PART_MAIN); + } + + // Leaves room at the ends of a vertical slider for the overhanging knob: half a line of text. + static void update_knob_clearance(lv_obj_t *container) { + const auto *font = lv_obj_get_style_text_font(container, LV_PART_MAIN); + auto clearance = static_cast(lv_font_get_line_height(font)) / 2; + if (clearance == lv_obj_get_style_pad_bottom(container, LV_PART_MAIN)) + return; + lv_obj_set_style_pad_bottom(container, clearance, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, clearance, LV_PART_MAIN); + } + + static void knob_clearance_cb(lv_event_t *event) { update_knob_clearance(lv_event_get_current_target_obj(event)); } + + static LvColorPickerType *from_event(lv_event_t *event) { + return static_cast(lv_event_get_user_data(event)); + } + static void hsv_changed_cb(lv_event_t *event) { from_event(event)->update_hsv_(); } + static void rgb_changed_cb(lv_event_t *event) { from_event(event)->update_rgb_(); } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + static void wheel_touch_cb(lv_event_t *event) { from_event(event)->update_from_wheel_touch_(); } + static void wheel_size_cb(lv_event_t *event) { from_event(event)->update_wheel_handle_(); } +#endif + static void size_changed_cb(lv_event_t *event) { from_event(event)->update_bar_thickness_(); } + + // Gives the horizontal bars the thickness of the vertical ones, half a hue column, so every + // knob is the same size. A knob is sized from the thickness of its bar. + void update_bar_thickness_() { + auto thickness = lv_obj_get_content_width(this->obj) * HUE_WIDTH_PCT / 200; + for (auto index : {SLIDER_SATURATION, SLIDER_BRIGHTNESS}) { + if (this->has_(index) && lv_obj_get_style_height(this->sliders_[index], LV_PART_MAIN) != thickness) + lv_obj_set_style_height(this->sliders_[index], thickness, LV_PART_MAIN); + } + } + + // LVGL also counts this size when working out the scroll area, so a widget given a fixed + // width must not claim it, or it would scroll whenever it is smaller. + static void self_size_cb(lv_event_t *event) { + auto *obj = lv_event_get_current_target_obj(event); + if (lv_obj_get_style_width(obj, LV_PART_MAIN) != LV_SIZE_CONTENT) + return; + const auto *font = lv_obj_get_style_text_font(obj, LV_PART_MAIN); + auto self_size = static_cast(lv_font_get_line_height(font)) * SELF_SIZE_FONT_MULTIPLE; + auto *point = static_cast(lv_event_get_param(event)); + point->x = self_size; + point->y = self_size; + } + + void update_text_() const { + // "#" + 3x "%02X" + null + char buf[8]; + snprintf(buf, sizeof(buf), "#%02X%02X%02X", static_cast(this->state_.r), + static_cast(this->state_.g), static_cast(this->state_.b)); + lv_label_set_text(this->color_text_, buf); + } + + void update_hue_bar_(SliderIndex index, lv_grad_dsc_t *grad, lv_color_t color) { + if (!this->has_(index)) + return; + grad->stops[1].color = color; + lv_obj_invalidate(this->sliders_[index]); + } + + void update_values_() const { + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->values_[index] == nullptr) + continue; + // up to 3 digits + null + char buf[4]; + snprintf(buf, sizeof(buf), "%d", static_cast(lv_slider_get_value(this->sliders_[index]))); + lv_label_set_text(this->values_[index], buf); + } + } + + void tint_knob_(SliderIndex index, lv_color_t color) const { + if (this->has_(index)) + lv_obj_set_style_bg_color(this->sliders_[index], color, LV_PART_KNOB); + } + + // Tints each knob with the colour of its bar at the current value. The brightness knob + // shows the selected colour instead, since its bar leaves out saturation. + void update_knobs_(lv_color_t full_hue) const { + if (!this->tint_knobs_) + return; + this->tint_knob_(SLIDER_HUE, full_hue); + this->tint_knob_(SLIDER_SATURATION, lv_color_hsv_to_rgb(this->hue_, this->saturation_, 100)); + this->tint_knob_(SLIDER_BRIGHTNESS, this->state_); + this->tint_knob_(SLIDER_RED, lv_color_make(255, 255 - this->state_.r, 255 - this->state_.r)); + this->tint_knob_(SLIDER_GREEN, lv_color_make(255 - this->state_.g, 255, 255 - this->state_.g)); + this->tint_knob_(SLIDER_BLUE, lv_color_make(255 - this->state_.b, 255 - this->state_.b, 255)); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Returns the wheel's radius, which is zero until it has been laid out. + double wheel_radius_(lv_area_t *area) const { + lv_obj_get_coords(this->wheel_, area); + return static_cast(area->x2 - area->x1) / 2.0; + } + + void update_wheel_handle_() { + if (this->wheel_handle_ == nullptr) + return; + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto angle_rad = static_cast(this->hue_) * (std::numbers::pi / 180.0); + auto distance = (static_cast(this->saturation_) / 100.0) * radius; + lv_obj_set_style_translate_x(this->wheel_handle_, static_cast(std::lround(distance * std::cos(angle_rad))), + LV_PART_MAIN); + lv_obj_set_style_translate_y(this->wheel_handle_, static_cast(std::lround(distance * std::sin(angle_rad))), + LV_PART_MAIN); + } + + // The angle from the centre gives the hue, the distance the saturation. Brightness is unchanged. + void update_from_wheel_touch_() { + lv_indev_t *indev = lv_indev_active(); + if (indev == nullptr) + return; + lv_point_t point; + lv_indev_get_point(indev, &point); + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto dx = point.x - (area.x1 + area.x2) / 2.0; + auto dy = point.y - (area.y1 + area.y2) / 2.0; + auto angle = std::atan2(dy, dx) * (180.0 / std::numbers::pi); + if (angle < 0) + angle += 360.0; + this->hue_ = static_cast(std::lround(angle)) % 360; + auto distance = std::sqrt(dx * dx + dy * dy); + this->saturation_ = static_cast(std::lround(std::min(100.0, distance * 100.0 / radius))); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + // Only sliders send this themselves; without it on_value would not fire for the wheel. + lv_obj_send_event(this->wheel_, LV_EVENT_VALUE_CHANGED, nullptr); + } +#endif + + // Lets events from the internal sliders reach the widget, where the triggers listen. Every + // object in between needs the flag; the widget itself does not, so events stop there. + static void bubble_events(lv_obj_t *obj) { + for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) { + auto *child = lv_obj_get_child(obj, i); + lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE); + bubble_events(child); + } + } + + void refresh_() { + lv_obj_set_style_bg_color(this->color_indicator_, this->state_, LV_PART_MAIN); + auto full_hue = lv_color_hsv_to_rgb(this->hue_, 100, 100); + this->update_hue_bar_(SLIDER_SATURATION, &this->saturation_bar_, full_hue); + this->update_hue_bar_(SLIDER_BRIGHTNESS, &this->brightness_bar_, full_hue); + this->update_text_(); + this->update_values_(); + this->update_knobs_(full_hue); +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + this->update_wheel_handle_(); +#endif + } + + // Sets the colour from the hue, saturation and brightness, and moves the RGB sliders to match. + void apply_hsv_() { + lv_color32_t c32 = + lv_color_to_32(lv_color_hsv_to_rgb(this->hue_, this->saturation_, this->brightness_), LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + this->set_slider_value_(SLIDER_RED, c32.red); + this->set_slider_value_(SLIDER_GREEN, c32.green); + this->set_slider_value_(SLIDER_BLUE, c32.blue); + this->refresh_(); + } + + void update_hsv_() { + this->hue_ = static_cast(this->slider_value_(SLIDER_HUE, this->hue_)); + this->saturation_ = static_cast(this->slider_value_(SLIDER_SATURATION, this->saturation_)); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + } + + void update_rgb_() { + this->state_ = Color(static_cast(this->slider_value_(SLIDER_RED, this->state_.r)), + static_cast(this->slider_value_(SLIDER_GREEN, this->state_.g)), + static_cast(this->slider_value_(SLIDER_BLUE, this->state_.b))); + auto hsv = lv_color_rgb_to_hsv(this->state_.r, this->state_.g, this->state_.b); + this->hue_ = hsv.h; + this->saturation_ = hsv.s; + this->brightness_ = hsv.v; + // A grey, white or black colour leaves a picker stuck there if nothing it has can move the + // saturation, or the brightness, away from zero. + bool stuck = false; + if (this->saturation_ == 0 && !this->can_change_saturation_()) { + this->saturation_ = 100; + stuck = true; + } + if (this->brightness_ == 0 && !this->can_change_brightness_()) { + this->brightness_ = 100; + stuck = true; + } + this->set_slider_value_(SLIDER_HUE, this->hue_); + this->set_slider_value_(SLIDER_SATURATION, this->saturation_); + this->set_slider_value_(SLIDER_BRIGHTNESS, this->brightness_); + if (stuck) { + this->apply_hsv_(); + } else { + this->refresh_(); + } + } + + void update_color_() { + this->set_slider_value_(SLIDER_RED, this->state_.r); + this->set_slider_value_(SLIDER_GREEN, this->state_.g); + this->set_slider_value_(SLIDER_BLUE, this->state_.b); + this->update_rgb_(); + lv_obj_invalidate(this->obj); + } + + // Shared by every colour picker; set up once by init_shared(). + inline static bool shared_ready{false}; + inline static lv_grad_dsc_t hue_bar{}; + inline static lv_grad_dsc_t red_bar{}; + inline static lv_grad_dsc_t green_bar{}; + inline static lv_grad_dsc_t blue_bar{}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + inline static lv_grad_dsc_t wheel_hue_grad{}; + inline static lv_grad_dsc_t wheel_white_grad{}; +#endif + inline static lv_style_t plain_box{}; + inline static lv_style_t slider_vert{}; + inline static lv_style_t slider_vert_knob{}; + inline static lv_style_t slider_horz{}; + inline static lv_style_t slider_horz_knob{}; + + // Per widget, since their far end follows the chosen hue. + lv_grad_dsc_t saturation_bar_{}; + lv_grad_dsc_t brightness_bar_{}; + Color state_{0x80, 0x80, 0x80}; + std::array sliders_{}; + std::array values_{}; + lv_obj_t *color_indicator_{}; + lv_obj_t *color_text_{}; + // Kept alongside state_ so a slider left out of the widget still has a value. + uint16_t hue_{0}; + uint8_t saturation_{0}; + uint8_t brightness_{50}; + const uint8_t sliders_mask_; + const bool use_wheel_; + bool tint_knobs_{true}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Null unless use_wheel_ is set. + lv_obj_t *wheel_{}; + lv_obj_t *wheel_handle_{}; +#endif +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_COLOR_PICKER diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 854ef0fb49..69e15bdeca 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -143,8 +143,8 @@ def get_theme_styles() -> dict[str, MockObj]: return _get_data(KEY_THEME_STYLES, {}) -def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]: - """Get the map of widget type names to the list of (style variable, part/state name)""" +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, str, str]]]: + """Get the map of widget type names to the list of (style variable, part, state)""" return _get_data(KEY_WIDGET_THEME_STYLES, {}) @@ -723,6 +723,7 @@ CONF_CHANGE_RATE = "change_rate" CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" +CONF_COLOR_PICKER = "color_picker" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" @@ -788,7 +789,6 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" -CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index 8db183fabe..df62e30f8a 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -13,6 +13,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import ( + CONF_COLOR_PICKER, CONF_END_ANGLE, CONF_GRADIENTS, CONF_OPA, @@ -22,6 +23,7 @@ from .defines import ( add_define, add_lv_use, add_warning, + get_lv_uses, ) from .lv_validation import ( lv_angle_degrees, @@ -164,7 +166,7 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): add_lv_use("gradient") - max_stops = 2 + max_stops = 7 if CONF_COLOR_PICKER in get_lv_uses() else 2 if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): add_warning( "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 1fef37e2ee..8231cd3b84 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -12,7 +12,7 @@ from .defines import ( STATES, LValidator, add_lv_use, - get_part_state_selector, + add_warning, get_styles_used, get_theme_styles, get_theme_update_requests, @@ -28,7 +28,7 @@ from .schemas import ( theme_update_schema, ) from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts +from .widgets import collect_parts, misplaced_obj_properties def has_style_props(config) -> bool: @@ -77,9 +77,20 @@ class LVStyle: return cls.named_styles.setdefault(id_name, LVStyle(id_name)) +def _warn_misplaced_obj_properties(config: dict, owner: str) -> None: + """Warn about widget properties in a style or theme, where they have no effect.""" + for where, prop in misplaced_obj_properties(config, include_top=True): + place = f"{owner} under '{where}'" if where else owner + add_warning( + f"'{prop}' applies to a widget, not to a style, so setting it in {place} has " + f"no effect. Set it on each widget instead." + ) + + async def styles_to_code(config): """Convert styles to C__ code.""" for style in config.get(CONF_STYLE_DEFINITIONS, ()): + _warn_misplaced_obj_properties(style, f"style '{style[CONF_ID].id}'") await create_style(style[CONF_ID].id, style) @@ -109,8 +120,8 @@ def _get_theme_style_name(w_name: str, part: str, state: str) -> str: return f"_lv_theme_style_{w_name}_{part}_{state}" -def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: - """Return a list of (style variable, part/state name) for all theme styles used by the given widget type.""" +def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, str, str]]: + """Return a list of (style variable, part, state) for all theme styles used by the given widget type.""" widget_styles = get_widget_theme_style_data() if w_name in widget_styles: return widget_styles[w_name] @@ -120,9 +131,7 @@ def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: for state in STATES + (CONF_DEFAULT,): style_name = _get_theme_style_name(w_name, part, state) if style_name in theme_styles: - style_list.append( - (theme_styles[style_name], get_part_state_selector(part, state)) - ) + style_list.append((theme_styles[style_name], part, state)) widget_styles[w_name] = style_list return style_list @@ -137,6 +146,9 @@ async def theme_to_code(config): :return: """ theme = config.get(CONF_THEME) or {} + for w_name, w_theme in theme.items(): + if isinstance(w_theme, dict): + _warn_misplaced_obj_properties(w_theme, f"the theme for '{w_name}'") requests = get_theme_update_requests() widget_names = [ w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index b48123b902..210314103e 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,5 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import ExitStack import sys from typing import Any @@ -45,6 +46,7 @@ from ..defines import ( STATES, LValidator, add_lv_use, + add_warning, get_options, get_part_state_selector, get_styles_used, @@ -54,6 +56,7 @@ from ..defines import ( ) from ..lv_validation import lv_int from ..lvcode import ( + LocalVariable, add_line_marks, lv, lv_add, @@ -182,7 +185,7 @@ class WidgetType: wid = config[CONF_ID] add_line_marks(wid) if self.is_compound(): - var = cg.new_Pvariable(wid) + var = cg.new_Pvariable(wid, *await self.get_ctor_args(config)) lv_add(var.set_obj(creator)) await self.on_create(var.obj, config) else: @@ -229,6 +232,37 @@ class WidgetType: :param config: Its configuration """ + async def get_ctor_args(self, config: dict) -> list: + """ + Get extra arguments for a compound widget's constructor. Anything the widget needs + before it builds its parts belongs here, since a setter would be called too late. + :param config: Its configuration + :return: + """ + return [] + + def obj_targets(self, w: "Widget", prop: str) -> list["Widget"]: + """ + Get the widgets one object property, as opposed to a style, applies to. A widget + built out of other widgets can point a property at the ones it really concerns, such + as sending a touch margin to the parts that are actually pressed. + :param w: The widget being configured + :param prop: The property name, one of OBJ_PROPERTIES + :return: + """ + return [w] + + def part_targets(self, w: "Widget", part: str) -> list[tuple["Widget", str]]: + """ + Get the widgets a part's styles should be applied to, each paired with the part to + use on it. A widget built out of other widgets can point a part at those, so that + styling it works the same as it does for a plain widget. + :param w: The widget being styled + :param part: The configured part name + :return: + """ + return [(w, part)] + def get_uses(self) -> tuple: """ Get a list of other widgets used by this one @@ -270,8 +304,9 @@ def apply_theme_styles(w: "Widget") -> None: """Apply the current theme's styles for this widget's type""" from ..styles import get_widget_theme_styles - for style, lv_state in get_widget_theme_styles(w.type.name): - w.add_style(style, lv_state) + for style, part, state in get_widget_theme_styles(w.type.name): + for target, target_part in w.type.part_targets(w, part): + target.add_style(style, get_part_state_selector(target_part, state)) def apply_debug_outline(w: "Widget") -> None: @@ -539,6 +574,38 @@ def collect_states(config): return states +def misplaced_obj_properties( + config: dict, include_top: bool = False +) -> Iterator[tuple[str, str]]: + """ + Find object properties set where LVGL has no way to apply them. They belong to a widget + as a whole, taking neither a part nor a state, so anywhere else they are quietly dropped. + :param config: A widget, style or theme configuration + :param include_top: Also report the top level, as for a style, which never holds them + :return: Each place one was found, as a part/state path ("" for the top level), paired + with the property name + """ + from ..schemas import OBJ_PROPERTIES + + def scan(sub: Any, where: str) -> Iterator[tuple[str, str]]: + if isinstance(sub, dict): + for prop in sorted(OBJ_PROPERTIES.intersection(sub)): + yield where, prop + + if include_top: + yield from scan(config, "") + for state in STATES: + if state in config: + yield from scan(config[state], state) + for part in PARTS: + if part not in config: + continue + yield from scan(config[part], part) + for state in STATES: + if state in config[part]: + yield from scan(config[part][state], f"{part}/{state}") + + def collect_parts(config): """ Collect properties and states for all widget parts @@ -610,6 +677,13 @@ def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: w.set_style("flex_track_place", literal(track)) +def _widget_name(w: Widget) -> str: + """The id a widget was given, for naming it in a warning.""" + if isinstance(w.config, dict) and (wid := w.config.get(CONF_ID)) is not None: + return str(wid) + return str(w.var) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" @@ -628,19 +702,42 @@ async def set_obj_properties(w: Widget, config): else: base_name = None _set_layout_options(w, layout, base_name) + for where, prop in misplaced_obj_properties(config): + add_warning( + f"'{prop}' applies to a widget as a whole, not to one part or state of it, so " + f"setting it under '{where}' of widget '{_widget_name(w)}' has no effect. Set it " + f"on the widget itself instead." + ) parts = collect_parts(config) for part, states in parts.items(): + targets = w.type.part_targets(w, part) for state, props in states.items(): - lv_state = get_part_state_selector(part, state) - for style_id in props.get(CONF_STYLES, ()): - w.add_style(style_id, lv_state) - for prop, value in { - k: v for k, v in props.items() if k in ALL_STYLES - }.items(): - if isinstance(ALL_STYLES[prop], LValidator): - value = await ALL_STYLES[prop].process(value) - prop_r = remap_property(prop) - w.set_style(prop_r, value, lv_state) + style_ids = props.get(CONF_STYLES, ()) + # Each value is worked out once and then given to every target. A widget built + # out of others has one target per part, and a lambda is written out in full + # wherever it appears, so with more than one target it goes into a variable + # rather than being repeated. The blocks holding those variables stay open + # until every target has been dealt with. + with ExitStack() as stack: + values = [] + for prop, value in { + k: v for k, v in props.items() if k in ALL_STYLES + }.items(): + validator = ALL_STYLES[prop] + if isinstance(validator, LValidator): + is_lambda = isinstance(value, cv.Lambda) + value = await validator.process(value) + if is_lambda and len(targets) > 1: + value = stack.enter_context( + LocalVariable(prop, validator.rtype, value, modifier="") + ) + values.append((remap_property(prop), value)) + for target, target_part in targets: + lv_state = get_part_state_selector(target_part, state) + for style_id in style_ids: + target.add_style(style_id, lv_state) + for prop_r, value in values: + target.set_style(prop_r, value, lv_state) if group := config.get(CONF_GROUP): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) @@ -663,7 +760,8 @@ async def set_obj_properties(w: Widget, config): w.set_state(state, value) for property in OBJ_PROPERTIES: - await w.set_property(property, config, lv_name="obj") + for target in w.type.obj_targets(w, property): + await target.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/esphome/components/lvgl/widgets/color_picker.py b/esphome/components/lvgl/widgets/color_picker.py new file mode 100644 index 0000000000..4f501ba0ee --- /dev/null +++ b/esphome/components/lvgl/widgets/color_picker.py @@ -0,0 +1,240 @@ +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR, CONF_HEIGHT, CONF_ITEMS, CONF_WIDTH +from esphome.core import CORE + +from ..defines import ( + CONF_COLOR_PICKER, + CONF_EXT_CLICK_AREA, + CONF_KNOB, + CONF_MAIN, + CONF_STYLES, + CONF_THEME, + DOMAIN, + add_define, + add_lv_use, + literal, +) +from ..lv_validation import lv_color, size +from ..lvcode import lv_add +from ..types import LvCompound, LvType +from . import Widget, WidgetType +from .label import CONF_LABEL +from .lv_bar import CONF_BAR +from .slider import CONF_SLIDER, slider_spec + +# esphome::Color, not lv_color_t: it carries the same value but exposes the components as +# `x.r`/`x.g`/`x.b`, and converts to lv_color_t on its own where LVGL needs one. +Color = cg.esphome_ns.class_("Color") + +lv_color_picker_t = LvType( + "LvColorPickerType", + parents=(LvCompound,), + largs=[(Color, "x")], + lvalue=lambda w: w.var.get_color(), + has_on_value=True, +) + +# Makes `lvgl.widget.update` with a `color:` report the change through `on_value`, the same +# as setting the value of any other widget does. +lv_color_picker_t.value_property = CONF_COLOR + +CONF_SLIDERS = "sliders" +CONF_WHEEL = "wheel" + +CONF_BG_COLOR = "bg_color" + +# The sliders the widget can be built from. The order must match +# LvColorPickerType::SliderIndex, since it decides which bit of the mask each one gets. +SLIDER_NAMES = ( + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", +) + +# Shorthand for the two sets of sliders that are usually wanted together. +SLIDER_GROUPS = { + "hsv": ("hue", "saturation", "brightness"), + "hsb": ("hue", "saturation", "brightness"), + "rgb": ("red", "green", "blue"), +} + +# The wheel draws hue and saturation itself, and the layout drops the RGB sliders to make +# room for it, leaving brightness as the only slider that still fits. +WHEEL_EXCLUDED_SLIDERS = ("hue", "saturation", "red", "green", "blue") + + +def validate_sliders(value: Any) -> list[str]: + """Expand any group names and drop repeats, keeping the widget's own slider order.""" + value = cv.ensure_list(cv.one_of(*SLIDER_NAMES, *SLIDER_GROUPS, lower=True))(value) + chosen = {name for item in value for name in SLIDER_GROUPS.get(item, (item,))} + if not chosen: + raise cv.Invalid("At least one slider is required") + return [name for name in SLIDER_NAMES if name in chosen] + + +COLOR_PICKER_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_COLOR): lv_color, + } +) + +COLOR_PICKER_SCHEMA = COLOR_PICKER_MODIFY_SCHEMA.extend( + { + cv.Optional(CONF_WIDTH, default="SIZE_CONTENT"): size, + cv.Optional(CONF_HEIGHT): cv.invalid("Height will be set to the same as width"), + cv.Optional(CONF_SLIDERS, default=list(SLIDER_NAMES)): validate_sliders, + cv.Optional(CONF_WHEEL, default=False): cv.boolean, + } +) + + +# Starting colour for a picker with no way to change saturation, which would otherwise be stuck +# at the grey it starts with. Saturation changes with its own slider, the wheel, or any of the +# red, green and blue sliders, so only these two sliders on their own leave it fixed. +SATURATED_DEFAULT_COLOR = 0xFF0000 +HUE_AND_BRIGHTNESS = {"hue", "brightness"} + + +def _knob_color_is_set(knob: dict) -> bool: + """Whether a knob is given a background colour, directly or through named styles.""" + return CONF_BG_COLOR in knob or bool(knob.get(CONF_STYLES)) + + +def _theme_sets_knob_color() -> bool: + """Whether any LVGL theme gives the color picker's knobs a background colour.""" + configs = CORE.config.get(DOMAIN) or [] + if isinstance(configs, dict): + configs = [configs] + return any( + _knob_color_is_set( + ((config.get(CONF_THEME) or {}).get(CONF_COLOR_PICKER) or {}).get( + CONF_KNOB, {} + ) + ) + for config in configs + ) + + +def _sliders(config: dict) -> list[str]: + """Get the sliders a widget was created with, in the widget's own order.""" + return config.get(CONF_SLIDERS) or list(SLIDER_NAMES) + + +class ColorPickerType(WidgetType): + def __init__(self) -> None: + super().__init__( + CONF_COLOR_PICKER, + lv_color_picker_t, + parts=(CONF_MAIN, CONF_ITEMS, CONF_KNOB), + schema=COLOR_PICKER_SCHEMA, + modify_schema=COLOR_PICKER_MODIFY_SCHEMA, + lv_name="obj", + ) + + def validate(self, value: Any) -> Any: + add_lv_use(CONF_COLOR_PICKER) + value = dict(value) + if cv.boolean(value.get(CONF_WHEEL, False)): + # The wheel is a conical hue gradient under a radial white-to-transparent one; + # LVGL's software renderer only draws either kind when this is enabled. + add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS") + if CONF_SLIDERS in value: + sliders = validate_sliders(value[CONF_SLIDERS]) + if any(name in sliders for name in WHEEL_EXCLUDED_SLIDERS): + raise cv.Invalid( + "the color wheel already shows hue and saturation, and replaces " + "the red, green and blue sliders with it; remove them from " + "'sliders' or set 'wheel: false'" + ) + else: + # Otherwise the schema default would build every slider including these. + value[CONF_SLIDERS] = [ + name for name in SLIDER_NAMES if name not in WHEEL_EXCLUDED_SLIDERS + ] + return super().validate(value) + + async def get_ctor_args(self, config: dict) -> list: + # The layout is worked out from the sliders, so they are needed before the widget is + # built rather than set afterwards. + return [ + literal( + " | ".join( + f"{lv_color_picker_t}::SLIDER_FLAG_{name.upper()}" + for name in _sliders(config) + ) + ), + config[CONF_WHEEL], + ] + + @staticmethod + def _sliders_of(w: Widget) -> list[Widget]: + """Wrap each slider the widget was created with, so it can be configured directly.""" + return [ + Widget( + w.var.get_slider( + literal(f"{lv_color_picker_t}::SLIDER_{name.upper()}") + ), + slider_spec, + ) + for name in _sliders(w.config or {}) + ] + + def obj_targets(self, w: Widget, prop: str) -> list[Widget]: + # Nothing is ever pressed on its own object, so the touch margin belongs on the + # sliders. Everything else, scrolling in particular, is a matter for the container + # holding them and so stays where it is. + if prop == CONF_EXT_CLICK_AREA: + return self._sliders_of(w) + return [w] + + def part_targets(self, w: Widget, part: str) -> list[tuple[Widget, str]]: + # The widget is made of sliders, so `items` and `knob` are the main and knob parts of + # each of those. Its own object is a plain container with neither. Only the sliders + # the widget was created with exist, so only those are styled. + if part == CONF_MAIN: + return [(w, part)] + target_part = CONF_MAIN if part == CONF_ITEMS else part + return [(slider, target_part) for slider in self._sliders_of(w)] + + async def to_code(self, w: Widget, config: dict) -> None: + # Only the configuration that creates the widget has `sliders`; an update does not. + creating = CONF_SLIDERS in config + if creating and config[CONF_WHEEL]: + # Without a wheel anywhere, its code and gradients are left out of the build. + cg.add_define("USE_LVGL_COLOR_PICKER_WHEEL") + color = config.get(CONF_COLOR) + if ( + color is None + and creating + and not config[CONF_WHEEL] + and set(config[CONF_SLIDERS]) <= HUE_AND_BRIGHTNESS + ): + color = SATURATED_DEFAULT_COLOR + # A hex colour validates to a plain int, so black must not be mistaken for absent. + if color is not None: + lv_add(w.var.set_color(await lv_color.process(color))) + # Each knob is normally tinted with the colour its own slider shows. That is applied + # as a local style, which outranks any style the knob has been given, so a background + # colour configured for the knob, here or in the theme, would never show; tinting + # gives way to it instead. + if _knob_color_is_set(config.get(CONF_KNOB, {})) or ( + creating and _theme_sets_knob_color() + ): + lv_add(w.var.set_tint_knobs(False)) + # The widget is square, so the height simply follows the configured width. + # SIZE_CONTENT works too: the widget reports a size based on its text font. + # Width is required when creating the widget but absent when updating one. + if (width := config.get(CONF_WIDTH)) is not None: + w.set_style(CONF_HEIGHT, await size.process(width), 0) + + def get_uses(self) -> tuple[str, ...]: + return ("flex", CONF_SLIDER, CONF_BAR, CONF_LABEL) + + +color_picker_spec = ColorPickerType() diff --git a/esphome/components/max17048/__init__.py b/esphome/components/max17048/__init__.py new file mode 100644 index 0000000000..ecd6a79dd5 --- /dev/null +++ b/esphome/components/max17048/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "max17048" diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp new file mode 100644 index 0000000000..67f9dbaac3 --- /dev/null +++ b/esphome/components/max17048/max17048.cpp @@ -0,0 +1,77 @@ +#include "max17048.h" +#include "esphome/core/log.h" + +namespace esphome::max17048 { + +// MAX17048/MAX17049 1 and 2 cell fuel gauges with ModelGauge +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/MAX17048-MAX17049.pdf + +ESPHOME_LOG_TAG(TAG, "max17048"); + +static constexpr uint8_t REG_VCELL = 0x02; // cell voltage +static constexpr uint8_t REG_SOC = 0x04; // state of charge +static constexpr uint8_t REG_VERSION = 0x08; // 0x001x +static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed + +static constexpr uint16_t VERSION_MASK = 0xFFF0; +static constexpr uint16_t VERSION_FAMILY = 0x0010; + +static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f; +static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f; +static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f; + +void MAX17048Component::setup() { + uint16_t version; + if (!this->read_byte_16(REG_VERSION, &version)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((version & VERSION_MASK) != VERSION_FAMILY) { + ESP_LOGW(TAG, "Unexpected version 0x%04X", version); + } +} + +void MAX17048Component::update() { + bool ok = true; + uint16_t raw; + if (this->battery_voltage_sensor_ != nullptr) { + if (this->read_byte_16(REG_VCELL, &raw)) { + this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB); + } else { + ok = false; + } + } + if (this->battery_level_sensor_ != nullptr) { + if (this->read_byte_16(REG_SOC, &raw)) { + this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB); + } else { + ok = false; + } + } + if (this->rate_sensor_ != nullptr) { + if (this->read_byte_16(REG_CRATE, &raw)) { + this->rate_sensor_->publish_state(static_cast(raw) * CRATE_PERCENT_PER_HOUR_PER_LSB); + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Register read failed")); + } +} + +void MAX17048Component::dump_config() { + ESP_LOGCONFIG(TAG, "MAX17048:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Rate", this->rate_sensor_); +} + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/max17048.h b/esphome/components/max17048/max17048.h new file mode 100644 index 0000000000..906d4a7c3f --- /dev/null +++ b/esphome/components/max17048/max17048.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::max17048 { + +class MAX17048Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(battery_voltage) + SUB_SENSOR(battery_level) + SUB_SENSOR(rate) + + public: + void setup() override; + void dump_config() override; + void update() override; +}; + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py new file mode 100644 index 0000000000..30cd0ec59f --- /dev/null +++ b/esphome/components/max17048/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BATTERY_VOLTAGE, + CONF_ID, + CONF_RATE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +UNIT_PERCENT_PER_HOUR = "%/h" + +max17048_ns = cg.esphome_ns.namespace("max17048") +MAX17048Component = max17048_ns.class_( + "MAX17048Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MAX17048Component), + cv.Optional(CONF_BATTERY_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_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT_PER_HOUR, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x36)) +) + + +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_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_RATE, var.set_rate_sensor) diff --git a/esphome/components/max31888/__init__.py b/esphome/components/max31888/__init__.py new file mode 100644 index 0000000000..880005d6e7 --- /dev/null +++ b/esphome/components/max31888/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@JiriPrchal"] +DOMAIN = "max31888" diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp new file mode 100644 index 0000000000..5ff8e6f1ef --- /dev/null +++ b/esphome/components/max31888/max31888.cpp @@ -0,0 +1,95 @@ +#include "max31888.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::max31888 { + +ESPHOME_LOG_TAG(TAG, "max31888"); + +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + +static constexpr uint8_t MAX31888_COMMAND_START_CONVERSION = 0x44; +static constexpr uint8_t MAX31888_COMMAND_READ = 0x33; +static constexpr uint8_t MAX31888_COMMAND_SOFT_RESET = 0x82; +static constexpr uint8_t MAX31888_REGISTER_FIFO_DATA = 0x08; +static constexpr uint8_t MAX31888_FIFO_READ_LENGTH = 0x01; // number of bytes to read, minus one +static constexpr uint16_t MAX31888_CONVERSION_TIME_MS = 20; +static constexpr float MAX31888_DEGREES_PER_LSB = 0.005f; + +// Sends a command and checks the checksum the device appends to it (Maxim CRC-16 over the command byte, +// sent inverted and low byte first) +bool MAX31888Sensor::send_checked_command_(uint8_t command) { + if (!this->send_command_(command)) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + const uint8_t low = this->bus_->read8(); + const uint8_t high = this->bus_->read8(); + if (crc16(&command, 1, 0, 0xa001, false, true) != encode_uint16(high, low)) { + this->status_set_warning(LOG_STR("command checksum invalid")); + return false; + } + return true; +} + +void MAX31888Sensor::setup() { + if (!this->check_address_or_index_()) + return; + if (!this->send_checked_command_(MAX31888_COMMAND_SOFT_RESET)) { + this->mark_failed(); + } +} + +void MAX31888Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "MAX31888 Sensor:"); + if (this->address_ == 0) { + ESP_LOGW(TAG, " Unable to select an address"); + return; + } + LOG_ONE_WIRE_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +void MAX31888Sensor::update() { + if (this->address_ == 0) + return; + + if (!this->send_checked_command_(MAX31888_COMMAND_START_CONVERSION)) { + this->publish_state(NAN); + return; + } + + this->set_timeout(CONVERSION_TIMEOUT_ID, MAX31888_CONVERSION_TIME_MS, [this] { + int16_t raw; + if (!this->read_temperature_(raw)) { + this->publish_state(NAN); + return; + } + const float temperature = raw * MAX31888_DEGREES_PER_LSB; + ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", this->get_name().c_str(), temperature); + this->status_clear_warning(); + this->publish_state(temperature); + }); +} + +bool MAX31888Sensor::read_temperature_(int16_t &raw) { + // The checksum covers the command and its two arguments as well as the data + uint8_t frame[7] = {MAX31888_COMMAND_READ, MAX31888_REGISTER_FIFO_DATA, MAX31888_FIFO_READ_LENGTH}; + if (!this->send_command_(frame[0])) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + this->bus_->write8(frame[1]); + this->bus_->write8(frame[2]); + for (size_t i = 3; i < sizeof(frame); i++) { + frame[i] = this->bus_->read8(); + } + if (crc16(frame, 5, 0, 0xa001, false, true) != encode_uint16(frame[6], frame[5])) { + this->status_set_warning(LOG_STR("checksum invalid")); + return false; + } + raw = static_cast(encode_uint16(frame[3], frame[4])); + return true; +} + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/max31888.h b/esphome/components/max31888/max31888.h new file mode 100644 index 0000000000..666f301cb0 --- /dev/null +++ b/esphome/components/max31888/max31888.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/one_wire/one_wire.h" + +namespace esphome::max31888 { + +class MAX31888Sensor final : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool send_checked_command_(uint8_t command); + /// Reads the latest conversion from the FIFO; false when the bus or the checksum fails + bool read_temperature_(int16_t &raw); +}; + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/sensor.py b/esphome/components/max31888/sensor.py new file mode 100644 index 0000000000..2809e7531a --- /dev/null +++ b/esphome/components/max31888/sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import one_wire, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +max31888_ns = cg.esphome_ns.namespace("max31888") + +MAX31888Sensor = max31888_ns.class_( + "MAX31888Sensor", + cg.PollingComponent, + sensor.Sensor, + one_wire.OneWireDevice, +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MAX31888Sensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(one_wire.one_wire_device_schema()) + .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 one_wire.register_one_wire_device(var, config) diff --git a/esphome/components/mcp9808/sensor.py b/esphome/components/mcp9808/sensor.py index 1daaa9c131..2238e3d853 100644 --- a/esphome/components/mcp9808/sensor.py +++ b/esphome/components/mcp9808/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -CODEOWNERS = ["@k7hpn"] DEPENDENCIES = ["i2c"] mcp9808_ns = cg.esphome_ns.namespace("mcp9808") diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 187fcfd8c0..835799c262 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -649,7 +649,7 @@ DriverChip( (0xF3, 0x52, 0xA4, 0x7F, 0x33, 0x34, 0xDF), ), ) -DriverChip( +ST7735 = DriverChip( "ST7735", color_order=MODE_RGB, width=128, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index a54bd19d88..f62bee5f60 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -4,6 +4,7 @@ from esphome.components.mipi import ( GMCTRP1, IDMOFF, IFMODE, + MODE_BGR, PWCTR1, PWCTR2, SETEXTC, @@ -11,7 +12,7 @@ from esphome.components.mipi import ( DriverChip, ) -from .ili import ILI9341, ST7789V +from .ili import ILI9341, ST7735, ST7789V # fmt: off DriverChip( @@ -71,3 +72,23 @@ GC9107.extend( cs_pin=14, requires={"psram"}, ) + +# M5Stack AtomS3R (ST7735 revision - May 14, 2026+) +ST7735.extend( + "M5STACK-ATOMS3R-ST7735", + native_width=132, + native_height=132, + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=3, + data_rate="40MHz", + color_order=MODE_BGR, + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, + requires={"psram"}, +) diff --git a/esphome/components/mistral_ir/__init__.py b/esphome/components/mistral_ir/__init__.py new file mode 100644 index 0000000000..deadd224d5 --- /dev/null +++ b/esphome/components/mistral_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mistral_ir" diff --git a/esphome/components/mistral_ir/climate.py b/esphome/components/mistral_ir/climate.py new file mode 100644 index 0000000000..cc152ca85d --- /dev/null +++ b/esphome/components/mistral_ir/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@lerhum"] + +mistral_ir_ns = cg.esphome_ns.namespace("mistral_ir") +MistralIR = mistral_ir_ns.class_("MistralIR", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MistralIR) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/mistral_ir/mistral_ir.cpp b/esphome/components/mistral_ir/mistral_ir.cpp new file mode 100644 index 0000000000..2eafcb348a --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.cpp @@ -0,0 +1,135 @@ +#include "mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::mistral_ir { + +ESPHOME_LOG_TAG(TAG, "mistral_ir.climate"); + +// Fixed bytes of every frame; bytes 1, 3, 4, 5, 6 and 11 are filled in per state +static const uint8_t FRAME_TEMPLATE[MISTRAL_FRAME_SIZE] PROGMEM = {0x56, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE8, 0x00, 0x60, 0x00}; +static const uint8_t FIXED_BYTES[] PROGMEM = {0, 2, 7, 8, 9, 10}; +static const uint8_t CHECKSUM_BYTES[] PROGMEM = {1, 3, 4, 5, 6, 8}; + +constexpr uint8_t MISTRAL_POWER_OFF = 0x00; +constexpr uint8_t MISTRAL_POWER_ON = 0x20; +constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F; +constexpr uint8_t MISTRAL_MODE_AUTO = 0x10; + +// The tables live in flash, so entries are copied out before use +struct ModeCode { + climate::ClimateMode mode; + uint8_t code; +}; +static const ModeCode MODES[] PROGMEM = { + {climate::CLIMATE_MODE_COOL, 0xC0}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x80}, + {climate::CLIMATE_MODE_HEAT, 0x60}, + {climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO}, +}; + +struct FanCode { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; +}; +static const FanCode FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0xA4, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x44, 0x40}, + {climate::CLIMATE_FAN_MEDIUM, 0x24, 0xC0}, + {climate::CLIMATE_FAN_HIGH, 0xA4, 0xA0}, +}; + +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +template static optional find_entry(const T (&table)[N], Pred pred) { + for (const T &slot : table) { + T entry = load_entry(slot); + if (pred(entry)) + return entry; + } + return {}; +} + +static uint8_t compute_checksum(const uint8_t *frame) { + uint8_t sum = 0; + for (const uint8_t &idx : CHECKSUM_BYTES) + sum += reverse_bits(frame[progmem_read_byte(&idx)]); + return reverse_bits(sum); +} + +static bool is_valid_frame(const uint8_t *frame) { + for (const uint8_t &slot : FIXED_BYTES) { + const uint8_t idx = progmem_read_byte(&slot); + if (frame[idx] != progmem_read_byte(&FRAME_TEMPLATE[idx])) + return false; + } + return frame[11] == compute_checksum(frame); +} + +void MistralIR::transmit_state() { + const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; }); + const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO); + const FanCode fan = + find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0])); + const uint8_t temp = lroundf(clamp(this->target_temperature, MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX)); + + uint8_t frame[MISTRAL_FRAME_SIZE]; + progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame)); + frame[1] = fan.byte1; + frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON; + frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO; + frame[5] = reverse_bits(static_cast(MISTRAL_TEMP_OFFSET - temp)); + frame[6] = fan.byte6; + frame[11] = compute_checksum(frame); + + auto transmit = this->transmitter_->transmit(); + remote_base::AEHAProtocol().encode(transmit.get_data(), MISTRAL_ADDRESS, frame, sizeof(frame)); + transmit.perform(); +} + +bool MistralIR::on_receive(remote_base::RemoteReceiveData data) { + auto aeha = remote_base::AEHAProtocol().decode(data); + if (!aeha.has_value() || aeha->address != MISTRAL_ADDRESS || aeha->data.size() != MISTRAL_FRAME_SIZE) + return false; + + const uint8_t *frame = aeha->data.data(); + if (!is_valid_frame(frame)) + return false; + + if (frame[3] == MISTRAL_POWER_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + if (frame[3] != MISTRAL_POWER_ON) + return false; + + // Decode everything before touching the state so an unknown code leaves it untouched + const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; }); + const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; }); + if (!mode.has_value() || !fan.has_value()) + return false; + + this->mode = mode->mode; + this->target_temperature = + clamp(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX); + this->fan_mode = fan->fan; + + ESP_LOGV(TAG, "Received Mistral frame: mode=%d temp=%.0f fan=%d", static_cast(this->mode), + this->target_temperature, static_cast(fan->fan)); + this->publish_state(); + return true; +} + +} // namespace esphome::mistral_ir diff --git a/esphome/components/mistral_ir/mistral_ir.h b/esphome/components/mistral_ir/mistral_ir.h new file mode 100644 index 0000000000..172af1d6a2 --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.h @@ -0,0 +1,38 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::mistral_ir { + +// Temperature range, Celsius +constexpr uint8_t MISTRAL_TEMP_MIN = 16; +constexpr uint8_t MISTRAL_TEMP_MAX = 30; + +constexpr uint16_t MISTRAL_ADDRESS = 0x322C; +constexpr size_t MISTRAL_FRAME_SIZE = 12; + +/* Reverse-engineered protocol for a "Mistral" (Belgian brand, model MHFE1126A / MR112C) split AC unit + from the 2000-2010 era, likely a rebadged OEM design. It is carried over AEHA IR timing (address + 0x322C) but does not match any protocol already implemented in ESPHome, so it is decoded/encoded + directly here rather than through the generic AEHA helper. + + Frame layout (12 bytes): 56 xx 08 xx xx xx xx 00 E8 00 60 xx + byte[1] / byte[6] -- fan speed + byte[3] -- power: 0x20 on, 0x00 off + byte[4] -- mode + byte[5] -- target temperature (bit-reversed offset from 0x9F) + byte[11] -- checksum: bit-reverse the sum of bit-reversed bytes 1, 3, 4, 5, 6, 8 +*/ +class MistralIR : public climate_ir::ClimateIR { + public: + MistralIR() + : climate_ir::ClimateIR(MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; +}; + +} // namespace esphome::mistral_ir diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 983a4777e8..969a6c8e8c 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -11,6 +11,7 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType, TemplateArgsType from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( @@ -177,6 +178,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" ), +) + +# The strings live only while the callback runs +_CUSTOM_CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CUSTOM_BINARY_SENSOR, "add_custom_binary_sensor_callback", @@ -293,3 +298,18 @@ async def to_code(config): cg.add_define(define_name) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + for entry in _CUSTOM_CALLBACK_AUTOMATIONS: + for conf in config.get(entry.conf_key, []): + await automation.build_callback_automation( + var, entry.callback_method, _owning_args(entry.args, conf), conf + ) + + +def _owning_args(args: TemplateArgsType, conf: ConfigType) -> TemplateArgsType: + """Pass std::string instead of StringRef to an automation that defers, as it keeps its arguments.""" + if not automation.has_non_synchronous_actions(conf): + return args + return [ + (cg.std_string if type_ is cg.StringRef else type_, name) + for type_, name in args + ] diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 1769c11018..781079c190 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -1,5 +1,6 @@ import base64 import binascii +import secrets from typing import Any import esphome.codegen as cg @@ -22,6 +23,11 @@ noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) +def generate_encryption_key() -> str: + """A fresh random key in the base64 form the yaml takes.""" + return base64.b64encode(secrets.token_bytes(32)).decode() + + def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index e0b7ca7c37..181dd63bb2 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -1,5 +1,6 @@ #include "noise_handshake.h" #ifdef USE_NOISE +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -111,6 +112,8 @@ int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { out_len = 0; + // The key agreement cannot be split across loop passes + UnavoidableBlockingScope scope; CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); diff --git a/esphome/components/ns2009/__init__.py b/esphome/components/ns2009/__init__.py new file mode 100644 index 0000000000..7cd4038a95 --- /dev/null +++ b/esphome/components/ns2009/__init__.py @@ -0,0 +1,8 @@ +import esphome.config_validation as cv + +CODEOWNERS = ["@tchilov"] +DOMAIN = "ns2009" + +CONFIG_SCHEMA = cv.invalid( + "This component should be used as platform of the Touchscreen component." +) diff --git a/esphome/components/ns2009/touchscreen/__init__.py b/esphome/components/ns2009/touchscreen/__init__.py new file mode 100644 index 0000000000..49bcf7a246 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +CODEOWNERS = ["@tchilov"] +DEPENDENCIES = ["i2c"] + +ns2009_ns = cg.esphome_ns.namespace("ns2009") +NS2009Component = ns2009_ns.class_( + "NS2009Component", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema(calibration_required=True) + .extend( + { + cv.GenerateID(): cv.declare_id(NS2009Component), + cv.Optional(CONF_THRESHOLD, default=40): cv.uint8_t, + } + ) + .extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await i2c.register_i2c_device(var, config) + await touchscreen.register_touchscreen(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) diff --git a/esphome/components/ns2009/touchscreen/ns2009.cpp b/esphome/components/ns2009/touchscreen/ns2009.cpp new file mode 100644 index 0000000000..ae1bdfe373 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.cpp @@ -0,0 +1,65 @@ +#include "ns2009.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ns2009 { + +ESPHOME_LOG_TAG(TAG, "ns2009"); + +static constexpr uint8_t GET_X = 0xC0; +static constexpr uint8_t GET_Y = 0xD0; +static constexpr uint8_t GET_Z = 0xE2; + +void NS2009Component::setup() { + if (!this->read_byte(GET_Z).has_value()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void NS2009Component::update_touches() { + auto data_z = this->read_byte(GET_Z); + if (!data_z.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("pressure")); + this->skip_update_ = true; + return; + } + uint8_t z = *data_z; + if (z <= this->threshold_) { + return; + } + + auto x = this->read_axis_(GET_X); + if (!x.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("X position")); + this->skip_update_ = true; + return; + } + auto y = this->read_axis_(GET_Y); + if (!y.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("Y position")); + this->skip_update_ = true; + return; + } + + ESP_LOGV(TAG, "X %4d Y %4d Z %3d", *x, *y, z); + this->add_raw_touch_position_(0, *x, *y, z); +} + +optional NS2009Component::read_axis_(uint8_t cmd) { + auto data = this->read_bytes<2>(cmd); + if (!data.has_value()) { + return {}; + } + return encode_uint16((*data)[0], (*data)[1]) >> 4; // 12 bit followed by 4 0's +} + +void NS2009Component::dump_config() { + ESP_LOGCONFIG(TAG, + "NS2009 Touchscreen:\n" + " Threshold: %u", + this->threshold_); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::ns2009 diff --git a/esphome/components/ns2009/touchscreen/ns2009.h b/esphome/components/ns2009/touchscreen/ns2009.h new file mode 100644 index 0000000000..75b675bfe9 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" + +namespace esphome::ns2009 { + +class NS2009Component : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Set the threshold for the touch detection. + void set_threshold(uint8_t threshold) { this->threshold_ = threshold; } + + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + /// Read a 12 bit axis value, or nothing when the read fails + optional read_axis_(uint8_t cmd); + + /// @brief The threshold at which a touch is registered. + uint8_t threshold_{40}; +}; + +} // namespace esphome::ns2009 diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 0a4880cf73..22d3afb775 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -10,6 +10,7 @@ static const uint8_t DALLAS_MODEL_DS1822 = 0x22; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_MODEL_DS1825 = 0x3B; static const uint8_t DALLAS_MODEL_DS28EA00 = 0x42; +static const uint8_t MAXIM_MODEL_MAX31888 = 0x54; const uint8_t ONE_WIRE_ROM_SELECT = 0x55; const uint8_t ONE_WIRE_ROM_SEARCH = 0xF0; @@ -76,6 +77,8 @@ const LogString *OneWireBus::get_model_str(uint8_t model) { return LOG_STR("DS1825"); case DALLAS_MODEL_DS28EA00: return LOG_STR("DS28EA00"); + case MAXIM_MODEL_MAX31888: + return LOG_STR("MAX31888"); default: return LOG_STR("Unknown"); } diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 25c7a78beb..6fca8bfdd9 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -166,10 +166,10 @@ _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, cv.Optional(CONF_CHANNEL): cv.int_range(min=11, max=26), - cv.Optional(CONF_NETWORK_KEY): cv.hex_int, + cv.Optional(CONF_NETWORK_KEY): cv.sensitive(cv.hex_int), cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, - cv.Optional(CONF_PSKC): cv.hex_int, + cv.Optional(CONF_PSKC): cv.sensitive(cv.hex_int), cv.Optional(CONF_MESH_LOCAL_PREFIX): cv.ipv6network, } ) @@ -243,7 +243,9 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), + cv.Optional(CONF_TLV): cv.sensitive( + cv.All(cv.string_strict, _validate_tlv_hex) + ), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 0447b968a3..a83195d42d 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,5 +1,9 @@ #include "ota_backend.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + namespace esphome::ota { bool version_is_older(const char *candidate, const char *reference) { @@ -30,6 +34,60 @@ bool version_is_older(const char *candidate, const char *reference) { } } +// Non-blocking; may be released by another task than the one that took it. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// uint32_t: narrower atomics compile to a masked CAS on Xtensa. +static std::atomic update_locked{0}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static bool update_lock_test_and_set() { return update_locked.exchange(1) == 0; } +static void update_lock_clear() { update_locked.store(0); } +#else +static bool update_locked = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// BK72xx has no atomic exchange. +static Mutex &update_lock_mutex() { + static Mutex mutex; + return mutex; +} +#endif + +static bool update_lock_test_and_set() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#else + // lwIP callbacks may run from an interrupt (RP2040). + InterruptLock guard; +#endif + if (update_locked) + return false; + update_locked = true; + return true; +} + +static void update_lock_clear() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#endif + update_locked = false; +} +#endif + +bool UpdateLock::try_acquire() { + if (!this->held_) + this->held_ = update_lock_test_and_set(); + return this->held_; +} + +void UpdateLock::release() { + if (!this->held_) + return; + update_lock_clear(); + this->held_ = false; +} + +void UpdateLock::force_release() { update_lock_clear(); } + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index a505d58efe..840d1ac11c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -51,6 +51,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, + OTA_RESPONSE_ERROR_UPDATE_BUSY = 0x95, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -95,6 +96,29 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +/// Serializes flash updates. Taken in begin(), released when the backend is destroyed, or kept until +/// reboot after a successful end(). Also for components writing the update partition outside OTA. +class UpdateLock { + public: + UpdateLock() = default; + ~UpdateLock() { this->release(); } + UpdateLock(const UpdateLock &) = delete; + UpdateLock &operator=(const UpdateLock &) = delete; + + /// Returns false if held elsewhere. + bool try_acquire(); + void release(); + bool is_held() const { return this->held_; } + /// Keep the lock set until reboot; is_held() becomes false. + void hold_until_reboot() { this->held_ = false; } + + protected: + /// Tests only. + static void force_release(); + + bool held_{false}; +}; + // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 02a5c5750f..33af42b6a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -14,6 +14,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -57,6 +59,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -66,7 +69,11 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; + Update.abort(); +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c322ed21f2..fc277bb5e4 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -16,6 +16,7 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 74f44c0b3b..d4d94be236 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -16,6 +16,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -63,6 +65,7 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -73,6 +76,8 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { } void ArduinoRP2OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; Update.end(); rp2::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index 15142869ac..93b9dcb75c 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -21,6 +21,7 @@ class ArduinoRP2OTABackend final { static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 5be55a5200..3d6148e64f 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -51,6 +51,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -303,10 +305,13 @@ OTAResponseTypes ESP8266OTABackend::end() { this->buffer_.reset(); esp8266::preferences_prevent_write(false); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void ESP8266OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 1f1ec37eee..bd4c9a1bce 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -23,6 +23,7 @@ class ESP8266OTABackend final { static constexpr bool supports_compression() { return true; } protected: + UpdateLock lock_; /// Erase flash sector if current address is at sector boundary bool erase_sector_if_needed_(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2316aad5cf..fd6fd7f6f1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -20,6 +20,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { @@ -196,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->update_partition_table(); + return this->hold_lock_if_ok_(this->update_partition_table()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; @@ -209,7 +211,7 @@ OTAResponseTypes IDFOTABackend::end() { } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { - return this->finalize_bootloader_update_(err); + return this->hold_lock_if_ok_(this->finalize_bootloader_update_(err)); } #endif if (err == ESP_OK) { @@ -242,7 +244,7 @@ OTAResponseTypes IDFOTABackend::end() { #endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { - return OTA_RESPONSE_OK; + return this->hold_lock_if_ok_(OTA_RESPONSE_OK); } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { @@ -260,6 +262,8 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 4f4093a594..d166bb361f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -36,6 +36,12 @@ class IDFOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; + OTAResponseTypes hold_lock_if_ok_(OTAResponseTypes result) { + if (result == OTA_RESPONSE_OK) + this->lock_.hold_until_reboot(); + return result; + } #ifdef USE_OTA_PARTITIONS // copy_dest_part non-null means the running app must be copied INTO this slot of the current // table before the new partition table is committed. The destination is in the current table diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 456fbb5863..923b0afe18 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -200,6 +200,8 @@ bool validate_executable_(const char *staging_path, const std::string &exe_path) std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. @@ -318,10 +320,13 @@ OTAResponseTypes HostOTABackend::end() { host::arm_reexec(this->final_path_); this->staging_path_.clear(); ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void HostOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; if (this->fd_ >= 0) { ::close(this->fd_); this->fd_ = -1; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index e53868f102..6c86fe10ba 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -22,6 +22,7 @@ class HostOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; md5::MD5Digest md5_{}; std::string staging_path_; std::string final_path_; diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 9498d4b4da..577385b217 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,6 @@ from collections import UserDict from collections.abc import Callable +from dataclasses import dataclass, field from functools import reduce import logging from pathlib import Path @@ -35,11 +36,45 @@ from esphome.const import ( CONF_VARS, __version__ as ESPHOME_VERSION, ) -from esphome.core import EsphomeError +from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) DOMAIN = "packages" + + +@dataclass(frozen=True) +class RemotePackageSource: + """A remote source a package was fetched from while processing the config.""" + + url: str + ref: str | None + + +@dataclass +class PackagesData: + """Per-run package state, keyed under DOMAIN in CORE.data.""" + + # Insertion-ordered and de-duplicated + remote_sources: dict[RemotePackageSource, None] = field(default_factory=dict) + + +def _get_data() -> PackagesData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PackagesData() + return CORE.data[DOMAIN] + + +def get_remote_package_sources() -> tuple[RemotePackageSource, ...]: + """Remote sources fetched for this config, in fetch order. + + store_yaml uses them to tell remote parts of the config from local files. + """ + if (data := CORE.data.get(DOMAIN)) is None: + return () + return tuple(data.remote_sources) + + # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -193,6 +228,9 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), ) + _get_data().remote_sources[ + RemotePackageSource(config[CONF_URL], config.get(CONF_REF)) + ] = None files: list[dict[str, Any]] = [] # ``repo_root`` is the directory containing ``.git`` and must be passed diff --git a/esphome/components/pd_pioneer_ir/__init__.py b/esphome/components/pd_pioneer_ir/__init__.py new file mode 100644 index 0000000000..3ca0ea4f78 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pd_pioneer_ir" diff --git a/esphome/components/pd_pioneer_ir/climate.py b/esphome/components/pd_pioneer_ir/climate.py new file mode 100644 index 0000000000..6bd3cc88d5 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/climate.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_USE_FAHRENHEIT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@martinsah"] + +pd_pioneer_ir_ns = cg.esphome_ns.namespace("pd_pioneer_ir") +PDPioneerIR = pd_pioneer_ir_ns.class_("PDPioneerIR", climate_ir.ClimateIR) + + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(PDPioneerIR).extend( + { + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + # The unit's auto mode is encoded but untested, so it is opt-in + cv.Optional(climate_ir.CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean, + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("pd_pioneer") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp new file mode 100644 index 0000000000..3d07d9006b --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp @@ -0,0 +1,111 @@ +#include "pd_pioneer_data.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +// Default frames stay in flash; the constructor copies them with progmem_memcpy. Even byte 5 is PWR_ON, byte 6 +// MODE_COOL +static const uint8_t DEFAULT_ODD[] PROGMEM = {0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, + 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00}; +static const uint8_t DEFAULT_EVEN[] PROGMEM = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, + 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80}; + +// Odd positions 5 and 6 and the even position 8 fan code, indexed from CLIMATE_FAN_AUTO to CLIMATE_FAN_HIGH +static const uint8_t FAN_CODES[][3] PROGMEM = { + {0x40, 0x20, 0x00}, {0x60, 0x40, 0x02}, {0x40, 0x60, 0x03}, {0x40, 0xC0, 0x05}}; +static constexpr size_t FAN_CODE_COUNT = sizeof(FAN_CODES) / sizeof(FAN_CODES[0]); + +ControlData::ControlData() : odd_(DEFAULT_ODD, sizeof(DEFAULT_ODD)), even_(DEFAULT_EVEN, sizeof(DEFAULT_EVEN)) {} + +void ControlData::set_temp(float temp_c) { + // The temperature travels as 31 - C in whole degrees at position 7, with bit 2 of position 12 adding a half + temp_c = clamp(temp_c, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + // Round to a quarter, then down to a half, so Fahrenheit set points encode as before + const int halves = static_cast(lroundf(temp_c * 4)) / 2; + this->even_[7] = static_cast(31 - halves / 2); + this->even_[12] = static_cast(0x80 | ((halves & 1) ? 0x04 : 0x00)); +} + +void ControlData::set_mode(ClimateMode mode) { + switch (mode) { + case ClimateMode::CLIMATE_MODE_OFF: + this->even_[5] = PWR_OFF; + this->even_[6] = MODE_HEAT; + this->even_[7] = 0x0C; + this->even_[12] = 0x80; + return; + case ClimateMode::CLIMATE_MODE_COOL: + this->even_[6] = MODE_COOL; + break; + case ClimateMode::CLIMATE_MODE_DRY: + this->even_[6] = MODE_DRY; + break; + case ClimateMode::CLIMATE_MODE_FAN_ONLY: + this->even_[6] = MODE_FAN_ONLY; + break; + case ClimateMode::CLIMATE_MODE_HEAT: + this->even_[6] = MODE_HEAT; + break; + default: + this->even_[6] = MODE_AUTO; + break; + } + this->even_[5] = PWR_ON; +} + +optional ControlData::get_mode(const PDPioneerData &even) { + if (even[5] == PWR_OFF) + return ClimateMode::CLIMATE_MODE_OFF; + switch (even[6]) { + case MODE_COOL: + return ClimateMode::CLIMATE_MODE_COOL; + case MODE_DRY: + return ClimateMode::CLIMATE_MODE_DRY; + case MODE_FAN_ONLY: + return ClimateMode::CLIMATE_MODE_FAN_ONLY; + case MODE_HEAT: + return ClimateMode::CLIMATE_MODE_HEAT; + case MODE_AUTO: + return ClimateMode::CLIMATE_MODE_HEAT_COOL; + default: + return {}; + } +} + +void ControlData::finalize() { + const size_t idx = this->fan_ >= climate::CLIMATE_FAN_AUTO && this->fan_ <= climate::CLIMATE_FAN_HIGH + ? this->fan_ - climate::CLIMATE_FAN_AUTO + : FAN_CODE_COUNT - 1; + this->odd_[5] = progmem_read_byte(&FAN_CODES[idx][0]); + this->odd_[6] = progmem_read_byte(&FAN_CODES[idx][1]); + this->odd_[7] = this->swing_; + this->even_[8] = static_cast(progmem_read_byte(&FAN_CODES[idx][2]) | + ((this->swing_ & SWING_VERTICAL) ? SWING_VERTICAL_EVEN_MASK : 0)); + this->odd_.finalize(); + this->even_.finalize(); +} + +ClimateFanMode ControlData::get_fan_mode(const PDPioneerData &even) { + const uint8_t code = even[8] & ~SWING_VERTICAL_EVEN_MASK; + for (size_t idx = 0; idx < FAN_CODE_COUNT; idx++) { + if (progmem_read_byte(&FAN_CODES[idx][2]) == code) + return static_cast(climate::CLIMATE_FAN_AUTO + idx); + } + return ClimateFanMode::CLIMATE_FAN_HIGH; +} + +void ControlData::set_swing_mode(climate::ClimateSwingMode mode) { + const bool vertical = mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH; + const bool horizontal = mode == climate::CLIMATE_SWING_HORIZONTAL || mode == climate::CLIMATE_SWING_BOTH; + this->swing_ = static_cast((vertical ? SWING_VERTICAL : 0) | (horizontal ? SWING_HORIZONTAL : 0)); +} + +climate::ClimateSwingMode ControlData::get_swing_mode(const PDPioneerData &even, bool horizontal) { + const bool vertical = (even[8] & SWING_VERTICAL_EVEN_MASK) == SWING_VERTICAL_EVEN_MASK; + if (vertical) + return horizontal ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_VERTICAL; + return horizontal ? climate::CLIMATE_SWING_HORIZONTAL : climate::CLIMATE_SWING_OFF; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.h b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h new file mode 100644 index 0000000000..ffea27dedd --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/climate/climate_mode.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +constexpr uint8_t PDPIONEER_TEMPC_MIN = 16; +constexpr uint8_t PDPIONEER_TEMPC_MAX = 31; + +using climate::ClimateMode; +using climate::ClimateFanMode; +using remote_base::PDPioneerData; + +class ControlData { + public: + ControlData(); + void set_temp(float temp_c); + void set_mode(ClimateMode mode); + void set_fan_mode(ClimateFanMode mode) { this->fan_ = mode; } + void set_swing_mode(climate::ClimateSwingMode mode); + void set_eco(bool enabled) { + if (enabled) + this->even_[5] = PWR_ECO; + } + + /// Write the fan and swing positions, which share bytes, then the checksums + void finalize(); + const PDPioneerData &odd() const { return this->odd_; } + const PDPioneerData &even() const { return this->even_; } + + /// Clamped to the set point range, so a frame from a sibling remote cannot publish an impossible value + static float get_temp(const PDPioneerData &even) { + const float temp = ((even[12] & 0x04) ? 31.5f : 31.0f) - static_cast(even[7]); + return clamp(temp, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + } + /// Empty for a mode byte this remote does not use + static optional get_mode(const PDPioneerData &even); + static bool get_eco(const PDPioneerData &even) { return even[5] == PWR_ECO; } + static ClimateFanMode get_fan_mode(const PDPioneerData &even); + /// Only the odd burst carries horizontal swing; the even burst carries vertical swing + static bool get_horizontal_swing(const PDPioneerData &odd) { return (odd[7] & SWING_HORIZONTAL) == SWING_HORIZONTAL; } + static climate::ClimateSwingMode get_swing_mode(const PDPioneerData &even, bool horizontal); + + protected: + static constexpr uint8_t MODE_HEAT = 0x01; + static constexpr uint8_t MODE_DRY = 0x02; + static constexpr uint8_t MODE_COOL = 0x03; + static constexpr uint8_t MODE_FAN_ONLY = 0x07; + static constexpr uint8_t MODE_AUTO = 0x08; + + static constexpr uint8_t PWR_ON = 0x24; + static constexpr uint8_t PWR_ECO = 0x25; + static constexpr uint8_t PWR_OFF = 0xA0; + + static constexpr uint8_t SWING_VERTICAL = 0x08; + static constexpr uint8_t SWING_HORIZONTAL = 0x90; + /// Vertical swing also sets this mask on even frame position 8 (fan sub-code). + static constexpr uint8_t SWING_VERTICAL_EVEN_MASK = 0x38; + + PDPioneerData odd_; + PDPioneerData even_; + ClimateFanMode fan_{climate::CLIMATE_FAN_HIGH}; + /// Odd-burst position 7: SWING_VERTICAL and SWING_HORIZONTAL flags + uint8_t swing_{0}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp new file mode 100644 index 0000000000..4ddcb3bc27 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp @@ -0,0 +1,59 @@ +#include "pd_pioneer_ir.h" +#include "esphome/core/log.h" + +namespace esphome::pd_pioneer_ir { + +ESPHOME_LOG_TAG(TAG, "pd_pioneer_ir"); + +void PDPioneerIR::setup() { + climate_ir::ClimateIR::setup(); + this->publish_state(); +} + +void PDPioneerIR::transmit_state() { + ControlData data; + data.set_mode(this->mode); + if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_FAN_ONLY) + data.set_temp(this->target_temperature); + data.set_fan_mode(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + data.set_swing_mode(this->swing_mode); + // ECO shares the power byte, so it must not undo an off command + data.set_eco(this->mode != climate::CLIMATE_MODE_OFF && this->preset == climate::CLIMATE_PRESET_ECO); + data.finalize(); + // The remote sends the two bursts as separate transmissions + this->transmit_(data.odd()); + this->transmit_(data.even()); +} + +bool PDPioneerIR::on_receive(remote_base::RemoteReceiveData data) { + auto frame = remote_base::PDPioneerProtocol().decode(data); + if (!frame.has_value()) + return false; + if (frame->is_odd_burst()) { + this->rx_horizontal_swing_ = ControlData::get_horizontal_swing(*frame); + return false; + } + // decode() only returns odd or even bursts; a mode byte this remote never sends is another device's frame + const optional decoded = ControlData::get_mode(*frame); + if (!decoded.has_value()) + return false; + // An auto-mode frame from the remote must not publish HEAT_COOL when the traits leave it out + if (this->get_traits().supports_mode(*decoded)) { + this->mode = *decoded; + } else { + ESP_LOGD(TAG, "Received mode %s is not enabled, keeping the current mode", + LOG_STR_ARG(climate::climate_mode_to_string(*decoded))); + } + // Off and fan-only bursts carry no target temperature + if (*decoded != climate::CLIMATE_MODE_OFF && *decoded != climate::CLIMATE_MODE_FAN_ONLY) + this->target_temperature = ControlData::get_temp(*frame); + this->fan_mode = ControlData::get_fan_mode(*frame); + this->swing_mode = ControlData::get_swing_mode(*frame, this->rx_horizontal_swing_); + // A missed odd burst must not carry this command's horizontal swing into the next one + this->rx_horizontal_swing_ = false; + this->preset = ControlData::get_eco(*frame) ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE; + this->publish_state(); + return true; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h new file mode 100644 index 0000000000..38b325dc1e --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "pd_pioneer_data.h" + +namespace esphome::pd_pioneer_ir { + +class PDPioneerIR : public climate_ir::ClimateIR { + public: + PDPioneerIR() + : climate_ir::ClimateIR(PDPIONEER_TEMPC_MIN, PDPIONEER_TEMPC_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}, + {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO}) {} + + void setup() override; + + void set_fahrenheit(bool value) { this->temperature_step_ = value ? 0.5f : 1.0f; } + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + // Horizontal swing from the odd burst, applied to the even burst that follows it + bool rx_horizontal_swing_{false}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/power_management/__init__.py b/esphome/components/power_management/__init__.py new file mode 100644 index 0000000000..5afbb648be --- /dev/null +++ b/esphome/components/power_management/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +IS_PLATFORM_COMPONENT = True +CODEOWNERS = ["@rwrozelle"] +DOMAIN = "power_management" + +power_management_ns = cg.esphome_ns.namespace("power_management") +PowerManagementComponent = power_management_ns.class_( + "PowerManagementComponent", cg.Component +) + + +async def register_power_management(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) diff --git a/esphome/components/power_management/power_management.h b/esphome/components/power_management/power_management.h new file mode 100644 index 0000000000..a0c026162a --- /dev/null +++ b/esphome/components/power_management/power_management.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::power_management { + +class PowerManagementComponent : public Component {}; + +} // namespace esphome::power_management diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp index 41524eebd2..57331a639c 100644 --- a/esphome/components/pzem6l24/pzem6l24.cpp +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -172,52 +172,52 @@ void PZEM6L24::publish_(const uint8_t *data) { // every sensor from flash. static constexpr SensorEntry SENSORS[] PROGMEM = { // Voltages (×0.1 V) - {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_a_sensor_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_sensor_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_sensor_, 4, REG_U16, SCALE_DECI}, // Currents (×0.01 A) - {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_a_sensor_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_sensor_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_sensor_, 10, REG_U16, SCALE_CENTI}, // Frequency (×0.01 Hz) - {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + {&PZEM6L24::frequency_sensor_, 12, REG_U16, SCALE_CENTI}, // Active powers (×0.1 W, signed) - {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_a_sensor_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_sensor_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_sensor_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_sensor_, 64, REG_I32, SCALE_DECI}, // Reactive powers (×0.1 var, signed) - {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_a_sensor_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_sensor_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_sensor_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_sensor_, 68, REG_I32, SCALE_DECI}, // Apparent powers (×0.1 VA, signed) - {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_a_sensor_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_sensor_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_sensor_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_sensor_, 72, REG_I32, SCALE_DECI}, // Power factors (×0.01), packed two per register: // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C - {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, - {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_a_sensor_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_sensor_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_sensor_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_sensor_, 78, REG_U8, SCALE_CENTI}, // Active energies (×0.1 kWh, unsigned) - {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_a_sensor_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_sensor_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_sensor_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_sensor_, 116, REG_U32, SCALE_DECI}, // Reactive energies (×0.1 kvarh, unsigned) - {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_a_sensor_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_sensor_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_sensor_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_sensor_, 120, REG_U32, SCALE_DECI}, // Apparent energies (×0.1 kVAh, unsigned) - {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_a_sensor_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_sensor_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_sensor_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_sensor_, 124, REG_U32, SCALE_DECI}, }; for (const SensorEntry &flash_entry : SENSORS) { @@ -268,41 +268,41 @@ void PZEM6L24::dump_config() { " Address: 0x%02X", this->address_); LOG_UPDATE_INTERVAL(this); - LOG_SENSOR(" ", "Voltage A", this->voltage_a_); - LOG_SENSOR(" ", "Voltage B", this->voltage_b_); - LOG_SENSOR(" ", "Voltage C", this->voltage_c_); - LOG_SENSOR(" ", "Current A", this->current_a_); - LOG_SENSOR(" ", "Current B", this->current_b_); - LOG_SENSOR(" ", "Current C", this->current_c_); - LOG_SENSOR(" ", "Active Power A", this->active_power_a_); - LOG_SENSOR(" ", "Active Power B", this->active_power_b_); - LOG_SENSOR(" ", "Active Power C", this->active_power_c_); - LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); - LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); - LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); - LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); - LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); - LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); - LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); - LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); - LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); - LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); - LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); - LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); - LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); - LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); - LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); - LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); - LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); - LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); - LOG_SENSOR(" ", "Frequency", this->frequency_); - LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); - LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); - LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); - LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); - LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); - LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); - LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_sensor_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_sensor_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_sensor_); + LOG_SENSOR(" ", "Current A", this->current_a_sensor_); + LOG_SENSOR(" ", "Current B", this->current_b_sensor_); + LOG_SENSOR(" ", "Current C", this->current_c_sensor_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_sensor_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_sensor_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_sensor_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_sensor_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_sensor_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_sensor_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_sensor_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_sensor_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_sensor_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_sensor_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_sensor_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_sensor_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_sensor_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_sensor_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_sensor_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_sensor_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_sensor_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_sensor_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_sensor_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_sensor_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_sensor_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_sensor_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_sensor_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_sensor_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_sensor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_sensor_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_sensor_); } // Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h index bf13f25053..9d8351f6f7 100644 --- a/esphome/components/pzem6l24/pzem6l24.h +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -36,54 +36,51 @@ constexpr std::array build_reset_pdu(ResetPhase phase) { } class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + protected: + // Ahead of the sensor pointers so the two bytes share the base class's tail padding. + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + public: - // Per-phase sensors; each setter is named after its config key - void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } - void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } - void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } - - void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } - void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } - void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } - - void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } - void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } - void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } - - void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } - void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } - void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } - - void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } - void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } - void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } - - void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } - void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } - void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } - - void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } - void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } - void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } - - void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } - void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } - void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } - - void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } - void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } - void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } - - // Combined sensors - void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } - void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } - void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } - void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } - void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } - void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } - void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } - void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + SUB_SENSOR(voltage_a) + SUB_SENSOR(voltage_b) + SUB_SENSOR(voltage_c) + SUB_SENSOR(current_a) + SUB_SENSOR(current_b) + SUB_SENSOR(current_c) + SUB_SENSOR(active_power_a) + SUB_SENSOR(active_power_b) + SUB_SENSOR(active_power_c) + SUB_SENSOR(reactive_power_a) + SUB_SENSOR(reactive_power_b) + SUB_SENSOR(reactive_power_c) + SUB_SENSOR(apparent_power_a) + SUB_SENSOR(apparent_power_b) + SUB_SENSOR(apparent_power_c) + SUB_SENSOR(power_factor_a) + SUB_SENSOR(power_factor_b) + SUB_SENSOR(power_factor_c) + SUB_SENSOR(active_energy_a) + SUB_SENSOR(active_energy_b) + SUB_SENSOR(active_energy_c) + SUB_SENSOR(reactive_energy_a) + SUB_SENSOR(reactive_energy_b) + SUB_SENSOR(reactive_energy_c) + SUB_SENSOR(apparent_energy_a) + SUB_SENSOR(apparent_energy_b) + SUB_SENSOR(apparent_energy_c) + SUB_SENSOR(frequency) + SUB_SENSOR(total_active_power) + SUB_SENSOR(total_reactive_power) + SUB_SENSOR(total_apparent_power) + SUB_SENSOR(total_power_factor) + SUB_SENSOR(total_active_energy) + SUB_SENSOR(total_reactive_energy) + SUB_SENSOR(total_apparent_energy) + public: // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. void reset_energy(ResetPhase phase_option); @@ -104,58 +101,6 @@ class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevic void request_failed_(std::span request_pdu); void read_failed_(); void read_finished_(); - - // Register reads accepted by the hub but not yet resolved by a terminal callback. - uint8_t reads_outstanding_{0}; - // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. - uint8_t consecutive_failures_{0}; - - // Per-phase sensors - sensor::Sensor *voltage_a_{nullptr}; - sensor::Sensor *voltage_b_{nullptr}; - sensor::Sensor *voltage_c_{nullptr}; - - sensor::Sensor *current_a_{nullptr}; - sensor::Sensor *current_b_{nullptr}; - sensor::Sensor *current_c_{nullptr}; - - sensor::Sensor *active_power_a_{nullptr}; - sensor::Sensor *active_power_b_{nullptr}; - sensor::Sensor *active_power_c_{nullptr}; - - sensor::Sensor *reactive_power_a_{nullptr}; - sensor::Sensor *reactive_power_b_{nullptr}; - sensor::Sensor *reactive_power_c_{nullptr}; - - sensor::Sensor *apparent_power_a_{nullptr}; - sensor::Sensor *apparent_power_b_{nullptr}; - sensor::Sensor *apparent_power_c_{nullptr}; - - sensor::Sensor *power_factor_a_{nullptr}; - sensor::Sensor *power_factor_b_{nullptr}; - sensor::Sensor *power_factor_c_{nullptr}; - - sensor::Sensor *active_energy_a_{nullptr}; - sensor::Sensor *active_energy_b_{nullptr}; - sensor::Sensor *active_energy_c_{nullptr}; - - sensor::Sensor *reactive_energy_a_{nullptr}; - sensor::Sensor *reactive_energy_b_{nullptr}; - sensor::Sensor *reactive_energy_c_{nullptr}; - - sensor::Sensor *apparent_energy_a_{nullptr}; - sensor::Sensor *apparent_energy_b_{nullptr}; - sensor::Sensor *apparent_energy_c_{nullptr}; - - // Combined sensors - sensor::Sensor *frequency_{nullptr}; - sensor::Sensor *total_active_power_{nullptr}; - sensor::Sensor *total_reactive_power_{nullptr}; - sensor::Sensor *total_apparent_power_{nullptr}; - sensor::Sensor *total_power_factor_{nullptr}; - sensor::Sensor *total_active_energy_{nullptr}; - sensor::Sensor *total_reactive_energy_{nullptr}; - sensor::Sensor *total_apparent_energy_{nullptr}; }; } // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index fd0430d85b..5e883acd7e 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -144,7 +144,7 @@ _COMBINED_QUANTITIES = { CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, } -# Every sensor by config key; the C++ setter is "set__sensor". +# Every sensor by config key; each needs its sensors(...) call in to_code _SENSORS = { f"{name}_{phase}": schema for name, schema in _PHASE_QUANTITIES.items() @@ -192,7 +192,39 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for key in _SENSORS: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors("voltage_a", var.set_voltage_a_sensor) + await sensors("voltage_b", var.set_voltage_b_sensor) + await sensors("voltage_c", var.set_voltage_c_sensor) + await sensors("current_a", var.set_current_a_sensor) + await sensors("current_b", var.set_current_b_sensor) + await sensors("current_c", var.set_current_c_sensor) + await sensors("active_power_a", var.set_active_power_a_sensor) + await sensors("active_power_b", var.set_active_power_b_sensor) + await sensors("active_power_c", var.set_active_power_c_sensor) + await sensors("reactive_power_a", var.set_reactive_power_a_sensor) + await sensors("reactive_power_b", var.set_reactive_power_b_sensor) + await sensors("reactive_power_c", var.set_reactive_power_c_sensor) + await sensors("apparent_power_a", var.set_apparent_power_a_sensor) + await sensors("apparent_power_b", var.set_apparent_power_b_sensor) + await sensors("apparent_power_c", var.set_apparent_power_c_sensor) + await sensors("power_factor_a", var.set_power_factor_a_sensor) + await sensors("power_factor_b", var.set_power_factor_b_sensor) + await sensors("power_factor_c", var.set_power_factor_c_sensor) + await sensors("active_energy_a", var.set_active_energy_a_sensor) + await sensors("active_energy_b", var.set_active_energy_b_sensor) + await sensors("active_energy_c", var.set_active_energy_c_sensor) + await sensors("reactive_energy_a", var.set_reactive_energy_a_sensor) + await sensors("reactive_energy_b", var.set_reactive_energy_b_sensor) + await sensors("reactive_energy_c", var.set_reactive_energy_c_sensor) + await sensors("apparent_energy_a", var.set_apparent_energy_a_sensor) + await sensors("apparent_energy_b", var.set_apparent_energy_b_sensor) + await sensors("apparent_energy_c", var.set_apparent_energy_c_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TOTAL_ACTIVE_POWER, var.set_total_active_power_sensor) + await sensors(CONF_TOTAL_REACTIVE_POWER, var.set_total_reactive_power_sensor) + await sensors(CONF_TOTAL_APPARENT_POWER, var.set_total_apparent_power_sensor) + await sensors(CONF_TOTAL_POWER_FACTOR, var.set_total_power_factor_sensor) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_TOTAL_APPARENT_ENERGY, var.set_total_apparent_energy_sensor) diff --git a/esphome/components/remko_ar715/__init__.py b/esphome/components/remko_ar715/__init__.py new file mode 100644 index 0000000000..18eb60df33 --- /dev/null +++ b/esphome/components/remko_ar715/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@naKruul"] +DOMAIN = "remko_ar715" diff --git a/esphome/components/remko_ar715/climate.py b/esphome/components/remko_ar715/climate.py new file mode 100644 index 0000000000..d640993a4c --- /dev/null +++ b/esphome/components/remko_ar715/climate.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL +import esphome.config_validation as cv +from esphome.const import CONF_SUPPORTS_HEAT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +remko_ar715_ns = cg.esphome_ns.namespace("remko_ar715") +RemkoAr715Climate = remko_ar715_ns.class_("RemkoAr715Climate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_schema(RemkoAr715Climate).extend( + { + cv.Optional(CONF_SUPPORTS_HEAT, default=False): cv.boolean_false, + cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false, + } +) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/remko_ar715/remko_ar715.cpp b/esphome/components/remko_ar715/remko_ar715.cpp new file mode 100644 index 0000000000..f6d5a2f62c --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.cpp @@ -0,0 +1,93 @@ +#include "remko_ar715.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::remko_ar715 { + +ESPHOME_LOG_TAG(TAG, "remko_ar715.climate"); + +// Timings from ir_ClimaButler.cpp (IRremoteESP8266), in microseconds +static constexpr uint16_t BIT_MARK = 511; +static constexpr uint16_t HDR_SPACE = 3492; +static constexpr uint16_t ONE_SPACE = 1540; +static constexpr uint16_t ZERO_SPACE = 548; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint8_t BITS = 52; + +static constexpr uint8_t POWER_ON = 0x3; +static constexpr uint8_t POWER_OFF = 0x0; +static constexpr uint8_t SWING_ON = 0x5; +static constexpr uint8_t SWING_OFF = 0x7; + +// Mode/fan byte: high nibble N3, low nibble N2 +static constexpr uint8_t MODE_FAN_AUTO = 0x80; +static constexpr uint8_t MODE_FAN_LOW = 0xA2; +static constexpr uint8_t MODE_FAN_MEDIUM = 0x91; +static constexpr uint8_t MODE_FAN_HIGH = 0x88; +static constexpr uint8_t MODE_DRY = 0xA4; +static constexpr uint8_t MODE_FAN_ONLY = 0xA3; + +static uint8_t fan_mode_byte(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_HIGH: + return MODE_FAN_HIGH; + case climate::CLIMATE_FAN_MEDIUM: + return MODE_FAN_MEDIUM; + case climate::CLIMATE_FAN_LOW: + return MODE_FAN_LOW; + default: + return MODE_FAN_AUTO; + } +} + +// 52-bit word, 13 nibbles MSB first: +// N12=0x8 (fixed), N11=power, N10..N5=0 (timer, unused), N4=swing, +// N3,N2=mode/fan, N1=temp-16, N0=checksum (0xF - sum(N12..N1)) & 0xF +static uint64_t build_frame(uint8_t power, uint8_t swing, uint8_t mode_fan, uint8_t temp) { + const uint8_t temp_nibble = (temp - 16) & 0xF; + const uint8_t sum = 0x8 + power + swing + (mode_fan >> 4) + (mode_fan & 0xF) + temp_nibble; + const uint8_t checksum = (0xF - sum) & 0xF; + return (uint64_t{0x8} << 48) | (uint64_t{power} << 44) | (uint32_t{swing} << 16) | (uint32_t{mode_fan} << 8) | + (temp_nibble << 4) | checksum; +} + +static void send_frame(remote_base::RemoteTransmitData *data, uint64_t code) { + data->set_carrier_frequency(CARRIER_FREQUENCY); + data->reserve(2 + BITS * 2 + 2); + data->item(BIT_MARK, HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) + data->item(BIT_MARK, (code >> i) & 1 ? ONE_SPACE : ZERO_SPACE); + // Stop bit and inter-frame gap + data->item(BIT_MARK, HDR_SPACE); +} + +void RemkoAr715Climate::transmit_state() { + const uint8_t temp = lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); + const bool power = this->mode != climate::CLIMATE_MODE_OFF; + const bool swing = this->swing_mode == climate::CLIMATE_SWING_VERTICAL; + + // DRY and FAN_ONLY carry no fan speed: mode and fan share the same nibbles + uint8_t mode_fan; + switch (this->mode) { + case climate::CLIMATE_MODE_DRY: + mode_fan = MODE_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + mode_fan = MODE_FAN_ONLY; + break; + default: + mode_fan = fan_mode_byte(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + break; + } + + const uint64_t code = build_frame(power ? POWER_ON : POWER_OFF, swing ? SWING_ON : SWING_OFF, mode_fan, temp); + ESP_LOGV(TAG, "Sending power=%s temp=%u swing=%s mode_fan=0x%02X code=0x%05" PRIX32 "%08" PRIX32, ONOFF(power), temp, + ONOFF(swing), mode_fan, static_cast(code >> 32), static_cast(code)); + + auto transmit = this->transmitter_->transmit(); + send_frame(transmit.get_data(), code); + transmit.perform(); +} + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remko_ar715/remko_ar715.h b/esphome/components/remko_ar715/remko_ar715.h new file mode 100644 index 0000000000..463617bbc6 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::remko_ar715 { + +// Remko AR-715 IR remote: 52-bit pulse-distance frame, full state in every command. +// IRremoteESP8266 calls it the ClimaButler protocol after a rebrand; Remko is the manufacturer. +// Protocol analysis: https://github.com/crankyoldgit/IRremoteESP8266/issues/1812 +// +// Supported devices: +// - Remko RKL series (RKL 490, 491, 494, 495) with AR-715 remote +// - Fischer ClimaButler RCS-SD43UAI / RCS-SD43UWI +// - TROTEC PAC 4600 +// - Novamatic CL 990 / CL 1590 +// - Rexair C15000N +// - freecom RCS-SD43UAI / RCS-SD43UWI + +static constexpr float TEMP_MIN = 16.0f; +static constexpr float TEMP_MAX = 30.0f; + +class RemkoAr715Climate final : public climate_ir::ClimateIR { + public: + RemkoAr715Climate() + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, + 1.0f, // temperature step + true, // supports dry + true, // supports fan only + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + protected: + void transmit_state() override; +}; + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index eca57bca8a..286d209e98 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -9,6 +9,7 @@ from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_BATTERY_LEVEL, CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, @@ -20,12 +21,17 @@ from esphome.const import ( CONF_DELTA, CONF_DEVICE, CONF_FAMILY, + CONF_FAN_MODE, + CONF_FORCE_UPDATE, CONF_GROUP, + CONF_HUMIDITY, CONF_ID, CONF_INDEX, CONF_INVERTED, + CONF_KEY, CONF_LEVEL, CONF_MAGNITUDE, + CONF_MODE, CONF_NBITS, CONF_ONE, CONF_PROTOCOL, @@ -37,6 +43,7 @@ from esphome.const import ( CONF_SOURCE, CONF_STATE, CONF_SYNC, + CONF_TEMPERATURE, CONF_TIMES, CONF_TRIGGER_ID, CONF_TYPE_ID, @@ -44,21 +51,26 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import coroutine +from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] DOMAIN = "remote_base" +CONF_ADDRESS_2 = "address_2" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" CONF_SUFFIX = "suffix" CONF_SUFFIX_BITS = "suffix_bits" +CONF_CONTROL = "control" +CONF_CONTROL_IN_KEY = "control_in_key" +CONF_ROLLING_CODE = "rolling_code" +CONF_EXTENSION = "extension" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -257,14 +269,23 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) +def _transmit_action( + name: str, + schema: cv.Schema, + validate: Callable[[ConfigType], ConfigType] | None, +) -> tuple[str, cv.Schema, Any]: + """The action name, its schema and the schema with ``validate`` applied.""" + schema = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + validator = schema if validate is None else cv.All(schema, validate) + return f"remote_transmitter.transmit_{name}", schema, validator + + def register_action( name, type_, schema, *, validate: Callable[[ConfigType], ConfigType] | None = None ): - validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) - if validate is not None: - validator = cv.All(validator, validate) + action, _, validator = _transmit_action(name, schema, validate) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", + action, type_, validator, synchronous=True, @@ -289,6 +310,75 @@ def register_action( return decorator +ProtocolField = ( + tuple[str, str, SafeExpType] | automation.ApplyField | automation.ApplyCall +) + + +def _protocol_field( + field: ProtocolField, +) -> automation.ApplyField | automation.ApplyCall: + if not isinstance(field, tuple): + return field + key, member, type_ = field + return automation.ApplyField(key, f"data.{member} = {{}}", type_) + + +_BYTES = cg.std_vector.template(cg.uint8) + + +def _code_table(config: ConfigType, value: list[int]) -> str: + return f"{automation.progmem_bytes('remote_base_code', value)}, {len(value)}" + + +def _bytes_field(conf_key: str, target: str) -> automation.ApplyField: + """A constant's flash table and length, or a lambda's vector; the callee takes both.""" + return automation.ApplyField(conf_key, target, _BYTES, const_fn=_code_table) + + +def register_protocol_action( + name: str, + protocol: str, + schema: cv.Schema, + *fields: ProtocolField, + validate: Callable[[ConfigType], ConfigType] | None = None, + call: str = "protocol_call", +) -> None: + """Register ``transmit_`` with no C++ action class. + + One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields + and sends it; the action is a core ``ApplyAction``. ``ApplyField`` and ``ApplyCall`` pass through + and target the call object, whose protocol data is ``data``. + ``call="encode_call"`` targets a plain transmit call whose ``encode()`` runs one of the + protocol's encoders, for payloads it encodes straight from a pointer and length. + """ + action, schema, validator = _transmit_action(name, schema, validate) + builder = automation.apply_action_builder( + action, + schema, + *(_protocol_field(field) for field in fields), + automation.ApplyField((CONF_REPEAT, CONF_TIMES), "set_send_times", cg.uint32), + automation.ApplyField( + (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 + ), + call=f"{call}<{ns.class_(f'{protocol}Protocol')}>", + id_key=CONF_TRANSMITTER_ID, + ) + + async def protocol_builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + _request_protocol_if_in_tree(name) + return await builder(config, action_id, template_arg, args) + + automation.register_action( + action, automation.ApplyAction, validator, synchronous=True + )(protocol_builder) + + def declare_protocol(name): data = ns.struct(f"{name}Data") binary_sensor_ = ns.class_(f"{name}BinarySensor", RemoteReceiverBinarySensorBase) @@ -372,9 +462,7 @@ async def build_dumpers(config): # Beo4 -Beo4Data, Beo4BinarySensor, Beo4Trigger, Beo4Action, Beo4Dumper = declare_protocol( - "Beo4" -) +Beo4Data, Beo4BinarySensor, Beo4Trigger, _, Beo4Dumper = declare_protocol("Beo4") BEO4_SCHEMA = cv.Schema( { cv.Required(CONF_SOURCE): cv.hex_uint8_t, @@ -408,14 +496,14 @@ def beo4_dumper(var, config): pass -@register_action("beo4", Beo4Action, BEO4_SCHEMA) -async def beo4_action(var, config, args): - template_ = await cg.templatable(config[CONF_SOURCE], args, cg.uint8) - cg.add(var.set_source(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "beo4", + "Beo4", + BEO4_SCHEMA, + (CONF_SOURCE, "source", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Brennenstuhl @@ -423,7 +511,7 @@ async def beo4_action(var, config, args): BrennenstuhlData, BrennenstuhlBinarySensor, BrennenstuhlTrigger, - BrennenstuhlAction, + _, BrennenstuhlDumper, ) = declare_protocol("Brennenstuhl") @@ -456,10 +544,12 @@ def brennenstuhl_dumper(var, config): pass -@register_action("brennenstuhl", BrennenstuhlAction, BRENNENSTUHL_SCHEMA) -async def brennenstuhl_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) +register_protocol_action( + "brennenstuhl", + "Brennenstuhl", + BRENNENSTUHL_SCHEMA, + (CONF_CODE, "code", cg.uint32), +) # ByronSX @@ -467,7 +557,7 @@ async def brennenstuhl_action(var, config, args): ByronSXData, ByronSXBinarySensor, ByronSXTrigger, - ByronSXAction, + _, ByronSXDumper, ) = declare_protocol("ByronSX") BYRONSX_SCHEMA = cv.Schema( @@ -503,12 +593,13 @@ def byronsx_dumper(var, config): pass -@register_action("byronsx", ByronSXAction, BYRONSX_SCHEMA) -async def byronsx_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "byronsx", + "ByronSX", + BYRONSX_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # CanalSat @@ -516,7 +607,7 @@ async def byronsx_action(var, config, args): CanalSatData, CanalSatBinarySensor, CanalSatTrigger, - CanalSatAction, + _, CanalSatDumper, ) = declare_protocol("CanalSat") CANALSAT_SCHEMA = cv.Schema( @@ -552,21 +643,21 @@ def canalsat_dumper(var, config): pass -@register_action("canalsat", CanalSatAction, CANALSAT_SCHEMA) -async def canalsat_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsat", + "CanalSat", + CANALSAT_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) ( CanalSatLDData, CanalSatLDBinarySensor, CanalSatLDTrigger, - CanalSatLDAction, + _, CanalSatLDDumper, ) = declare_protocol("CanalSatLD") CANALSATLD_SCHEMA = cv.Schema( @@ -602,14 +693,14 @@ def canalsatld_dumper(var, config): pass -@register_action("canalsatld", CanalSatLDAction, CANALSATLD_SCHEMA) -async def canalsatld_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsatld", + "CanalSatLD", + CANALSATLD_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Coolix @@ -617,7 +708,7 @@ async def canalsatld_action(var, config, args): CoolixData, CoolixBinarySensor, CoolixTrigger, - CoolixAction, + _, CoolixDumper, ) = declare_protocol("Coolix") @@ -647,12 +738,13 @@ def coolix_binary_sensor(var, config): cg.add(var.set_data(cg.ArrayInitializer(0, config))) -@register_action("coolix", CoolixAction, COOLIX_BASE_SCHEMA) -async def coolix_action(var, config, args): - template_ = await cg.templatable(config[CONF_FIRST], args, cg.uint32) - cg.add(var.set_first(template_)) - template_ = await cg.templatable(config[CONF_SECOND], args, cg.uint32) - cg.add(var.set_second(template_)) +register_protocol_action( + "coolix", + "Coolix", + COOLIX_BASE_SCHEMA, + (CONF_FIRST, "first", cg.uint32), + (CONF_SECOND, "second", cg.uint32), +) @register_trigger("coolix", CoolixTrigger, CoolixData) @@ -666,9 +758,7 @@ def coolix_dumper(var, config): # Dish -DishData, DishBinarySensor, DishTrigger, DishAction, DishDumper = declare_protocol( - "Dish" -) +DishData, DishBinarySensor, DishTrigger, _, DishDumper = declare_protocol("Dish") DISH_SCHEMA = cv.Schema( { cv.Optional(CONF_ADDRESS, default=1): cv.int_range(min=1, max=16), @@ -700,18 +790,17 @@ def dish_dumper(var, config): pass -@register_action("dish", DishAction, DISH_SCHEMA) -async def dish_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "dish", + "Dish", + DISH_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Dooya -DooyaData, DooyaBinarySensor, DooyaTrigger, DooyaAction, DooyaDumper = declare_protocol( - "Dooya" -) +DooyaData, DooyaBinarySensor, DooyaTrigger, _, DooyaDumper = declare_protocol("Dooya") DOOYA_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.hex_int_range(0, 16777215), @@ -747,22 +836,19 @@ def dooya_dumper(var, config): pass -@register_action("dooya", DooyaAction, DOOYA_SCHEMA) -async def dooya_action(var, config, args): - template_ = await cg.templatable(config[CONF_ID], args, cg.uint32) - cg.add(var.set_id(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_BUTTON], args, cg.uint8) - cg.add(var.set_button(template_)) - template_ = await cg.templatable(config[CONF_CHECK], args, cg.uint8) - cg.add(var.set_check(template_)) +register_protocol_action( + "dooya", + "Dooya", + DOOYA_SCHEMA, + (CONF_ID, "id", cg.uint32), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_BUTTON, "button", cg.uint8), + (CONF_CHECK, "check", cg.uint8), +) # Dyson -DysonData, DysonBinarySensor, DysonTrigger, DysonAction, DysonDumper = declare_protocol( - "Dyson" -) +DysonData, DysonBinarySensor, DysonTrigger, _, DysonDumper = declare_protocol("Dyson") DYSON_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.hex_uint16_t, @@ -794,16 +880,17 @@ def dyson_dumper(var, config): pass -@register_action("dyson", DysonAction, DYSON_SCHEMA) -async def dyson_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint16) - cg.add(var.set_code(template_)) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.uint8) - cg.add(var.set_index(template_)) +register_protocol_action( + "dyson", + "Dyson", + DYSON_SCHEMA, + (CONF_CODE, "code", cg.uint16), + (CONF_INDEX, "index", cg.uint8), +) # JVC -JVCData, JVCBinarySensor, JVCTrigger, JVCAction, JVCDumper = declare_protocol("JVC") +JVCData, JVCBinarySensor, JVCTrigger, _, JVCDumper = declare_protocol("JVC") JVC_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint32_t}) @@ -829,14 +916,16 @@ def jvc_dumper(var, config): pass -@register_action("jvc", JVCAction, JVC_SCHEMA) -async def jvc_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) +register_protocol_action( + "jvc", + "JVC", + JVC_SCHEMA, + (CONF_DATA, "data", cg.uint32), +) # LG -LGData, LGBinarySensor, LGTrigger, LGAction, LGDumper = declare_protocol("LG") +LGData, LGBinarySensor, LGTrigger, _, LGDumper = declare_protocol("LG") LG_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -868,12 +957,13 @@ def lg_dumper(var, config): pass -@register_action("lg", LGAction, LG_SCHEMA) -async def lg_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "lg", + "LG", + LG_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # MagiQuest @@ -881,7 +971,7 @@ async def lg_action(var, config, args): MagiQuestData, MagiQuestBinarySensor, MagiQuestTrigger, - MagiQuestAction, + _, MagiQuestDumper, ) = declare_protocol("MagiQuest") @@ -892,16 +982,22 @@ MAGIQUEST_SCHEMA = cv.Schema( } ) +# A wand_id of 0 matches any wand. Magnitude is still accepted but not matched on. +MAGIQUEST_BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WAND_ID, default=0): cv.hex_uint32_t, + cv.Optional(CONF_MAGNITUDE): cv.hex_uint16_t, + } +) -@register_binary_sensor("magiquest", MagiQuestBinarySensor, MAGIQUEST_SCHEMA) + +@register_binary_sensor( + "magiquest", MagiQuestBinarySensor, MAGIQUEST_BINARY_SENSOR_SCHEMA +) def magiquest_binary_sensor(var, config): cg.add( var.set_data( - cg.StructInitializer( - MagiQuestData, - ("magnitude", config[CONF_MAGNITUDE]), - ("wand_id", config[CONF_WAND_ID]), - ) + cg.StructInitializer(MagiQuestData, ("wand_id", config[CONF_WAND_ID])) ) ) @@ -916,12 +1012,13 @@ def magiquest_dumper(var, config): pass -@register_action("magiquest", MagiQuestAction, MAGIQUEST_SCHEMA) -async def magiquest_action(var, config, args): - template_ = await cg.templatable(config[CONF_WAND_ID], args, cg.uint32) - cg.add(var.set_wand_id(template_)) - template_ = await cg.templatable(config[CONF_MAGNITUDE], args, cg.uint16) - cg.add(var.set_magnitude(template_)) +register_protocol_action( + "magiquest", + "MagiQuest", + MAGIQUEST_SCHEMA, + (CONF_WAND_ID, "wand_id", cg.uint32), + (CONF_MAGNITUDE, "magnitude", cg.uint16), +) # Microchip HCS301 KeeLoq OOK @@ -929,7 +1026,7 @@ async def magiquest_action(var, config, args): KeeloqData, KeeloqBinarySensor, KeeloqTrigger, - KeeloqAction, + _, KeeloqDumper, ) = declare_protocol("Keeloq") KEELOQ_SCHEMA = cv.Schema( @@ -947,8 +1044,8 @@ KEELOQ_SCHEMA = cv.Schema( def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: # A lambda is only known on the device. A plain value has to fit the width. - suffix = config[CONF_SUFFIX] - bits = config[CONF_SUFFIX_BITS] + suffix = config.get(CONF_SUFFIX, 0) + bits = config.get(CONF_SUFFIX_BITS, 0) if isinstance(suffix, int) and isinstance(bits, int) and suffix >> bits: raise cv.Invalid( f"suffix 0x{suffix:X} does not fit in {bits} suffix_bits", [CONF_SUFFIX] @@ -958,10 +1055,9 @@ def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( { - cv.Optional(CONF_SUFFIX, default=0): cv.All( - cv.hex_int, cv.Range(min=0, max=0xFFFF) - ), - cv.Optional(CONF_SUFFIX_BITS, default=0): cv.int_range(min=0, max=16), + # No defaults: an absent key leaves the data at 0 and emits nothing. + cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), + cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), } ) @@ -989,33 +1085,28 @@ def keeloq_dumper(var, config): pass -@register_action( - "keeloq", KeeloqAction, KEELOQ_ACTION_SCHEMA, validate=_keeloq_suffix_fits +register_protocol_action( + "keeloq", + "Keeloq", + KEELOQ_ACTION_SCHEMA, + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CODE, "encrypted", cg.uint32), + (CONF_COMMAND, "command", cg.uint8), + (CONF_LEVEL, "vlow", cg.bool_), + (CONF_SUFFIX, "suffix", cg.uint16), + (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), + validate=_keeloq_suffix_fits, ) -async def keeloq_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint32) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_encrypted(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) - cg.add(var.set_vlow(template_)) - # Without suffix bits nothing of the suffix is sent, so both stay at their C++ default. - if (suffix_bits := config[CONF_SUFFIX_BITS]) != 0: - template_ = await cg.templatable(config[CONF_SUFFIX], args, cg.uint16) - cg.add(var.set_suffix(template_)) - template_ = await cg.templatable(suffix_bits, args, cg.uint8) - cg.add(var.set_suffix_bits(template_)) # NEC -NECData, NECBinarySensor, NECTrigger, NECAction, NECDumper = declare_protocol("NEC") +NECData, NECBinarySensor, NECTrigger, _, NECDumper = declare_protocol("NEC") NEC_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Required(CONF_COMMAND): cv.hex_uint16_t, - cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.uint16_t, + # Non-RMT transmitters hold an interrupt lock for the whole send, ~108 ms per extra repeat + cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.int_range(min=1, max=10), } ) @@ -1044,19 +1135,19 @@ def nec_dumper(var, config): pass -@register_action("nec", NECAction, NEC_SCHEMA) -async def nec_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint16) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint16) - cg.add(var.set_command_repeats(template_)) +register_protocol_action( + "nec", + "NEC", + NEC_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint16), + (CONF_COMMAND_REPEATS, "command_repeats", cg.uint16), +) # Onkyo RI -OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, OnkyoRIAction, OnkyoRIDumper = ( - declare_protocol("OnkyoRI") +OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, _, OnkyoRIDumper = declare_protocol( + "OnkyoRI" ) ONKYORI_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_int_range(0, 0xFFF)}) @@ -1083,12 +1174,12 @@ def onkyori_dumper(var: MockObj, config: ConfigType) -> None: """The dumper takes no options.""" -@register_action("onkyori", OnkyoRIAction, ONKYORI_SCHEMA) -async def onkyori_action( - var: MockObj, config: ConfigType, args: TemplateArgsType -) -> None: - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint16) - cg.add(var.set_data(template_)) +register_protocol_action( + "onkyori", + "OnkyoRI", + ONKYORI_SCHEMA, + (CONF_DATA, "data", cg.uint16), +) # Pioneer @@ -1096,7 +1187,7 @@ async def onkyori_action( PioneerData, PioneerBinarySensor, PioneerTrigger, - PioneerAction, + _, PioneerDumper, ) = declare_protocol("Pioneer") PIONEER_SCHEMA = cv.Schema( @@ -1130,12 +1221,31 @@ def pioneer_dumper(var, config): pass -@register_action("pioneer", PioneerAction, PIONEER_SCHEMA) -async def pioneer_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint16) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint16) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "pioneer", + "Pioneer", + PIONEER_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint16), + (CONF_RC_CODE_2, "rc_code_2", cg.uint16), +) + + +# PioneerWyt +PioneerWytData = ns.class_("PioneerWytData") +PIONEER_WYT_SCHEMA = cv.Schema( + { + # 13 bytes get the checksum appended; a 14th byte is replaced by the recomputed checksum + cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=14)), + } +) + +register_protocol_action( + "pioneer_wyt", + "PioneerWyt", + PIONEER_WYT_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PioneerWytData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # Pronto @@ -1143,7 +1253,7 @@ async def pioneer_action(var, config, args): ProntoData, ProntoBinarySensor, ProntoTrigger, - ProntoAction, + _, ProntoDumper, ) = declare_protocol("Pronto") PRONTO_SCHEMA = cv.Schema( @@ -1177,10 +1287,10 @@ def pronto_dumper(var, config): pass -@register_action("pronto", ProntoAction, PRONTO_SCHEMA) -async def pronto_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.std_string) - cg.add(var.set_data(template_)) +# delta only matters when matching a received code +register_protocol_action( + "pronto", "Pronto", PRONTO_SCHEMA, (CONF_DATA, "data", cg.std_string) +) # Gobox @@ -1188,7 +1298,7 @@ async def pronto_action(var, config, args): GoboxData, GoboxBinarySensor, GoboxTrigger, - GoboxAction, + _, GoboxDumper, ) = declare_protocol("Gobox") GOBOX_SCHEMA = cv.Schema( @@ -1220,10 +1330,12 @@ def gobox_dumper(var, config): pass -@register_action("gobox", GoboxAction, GOBOX_SCHEMA) -async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) - cg.add(var.set_code(template_)) +register_protocol_action( + "gobox", + "Gobox", + GOBOX_SCHEMA, + (CONF_CODE, "code", cg.uint64), +) # Roomba @@ -1231,7 +1343,7 @@ async def gobox_action(var, config, args): RoombaData, RoombaBinarySensor, RoombaTrigger, - RoombaAction, + _, RoombaDumper, ) = declare_protocol("Roomba") ROOMBA_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint8_t}) @@ -1259,16 +1371,16 @@ def roomba_dumper(var, config): pass -@register_action("roomba", RoombaAction, ROOMBA_SCHEMA) -async def roomba_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint8) - cg.add(var.set_data(template_)) +register_protocol_action( + "roomba", + "Roomba", + ROOMBA_SCHEMA, + (CONF_DATA, "data", cg.uint8), +) # Sony -SonyData, SonyBinarySensor, SonyTrigger, SonyAction, SonyDumper = declare_protocol( - "Sony" -) +SonyData, SonyBinarySensor, SonyTrigger, _, SonyDumper = declare_protocol("Sony") SONY_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -1300,16 +1412,17 @@ def sony_dumper(var, config): pass -@register_action("sony", SonyAction, SONY_SCHEMA) -async def sony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "sony", + "Sony", + SONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # Symphony -SymphonyData, SymphonyBinarySensor, SymphonyTrigger, SymphonyAction, SymphonyDumper = ( +SymphonyData, SymphonyBinarySensor, SymphonyTrigger, _, SymphonyDumper = ( declare_protocol("Symphony") ) SYMPHONY_SCHEMA = cv.Schema( @@ -1344,14 +1457,14 @@ def symphony_dumper(var, config): pass -@register_action("symphony", SymphonyAction, SYMPHONY_SCHEMA) -async def symphony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "symphony", + "Symphony", + SYMPHONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Raw @@ -1430,27 +1543,39 @@ async def raw_action(var, config, args): DraytonData, DraytonBinarySensor, DraytonTrigger, - DraytonAction, + _, DraytonDumper, ) = declare_protocol("Drayton") DRAYTON_SCHEMA = cv.Schema( { - cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), - cv.Required(CONF_CHANNEL): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), - cv.Required(CONF_COMMAND): cv.All(cv.hex_int, cv.Range(min=0, max=0x7F)), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_DATA): cv.hex_int_range(min=0, max=0xF), + cv.Optional(CONF_CHANNEL): cv.hex_int_range(min=0, max=0x1F), + cv.Optional(CONF_COMMAND): cv.hex_int_range(min=0, max=0x7F), } ) +# The legacy command is the 4 data bits followed by this many checksum bits +DRAYTON_NBITS_CHECKSUM_IN_COMMAND = 3 +# Either `data`, or the legacy `channel` + `command` pair (raw packet bits) +_validate_drayton_form = cv.All( + cv.has_none_or_all_keys(CONF_CHANNEL, CONF_COMMAND), + cv.has_exactly_one_key(CONF_DATA, CONF_COMMAND), +) -@register_binary_sensor("drayton", DraytonBinarySensor, DRAYTON_SCHEMA) +@register_binary_sensor( + "drayton", DraytonBinarySensor, cv.All(DRAYTON_SCHEMA, _validate_drayton_form) +) def drayton_binary_sensor(var, config): + # Matching compares address and data only + if (command := config.get(CONF_COMMAND)) is not None: + data = command >> DRAYTON_NBITS_CHECKSUM_IN_COMMAND + else: + data = config[CONF_DATA] cg.add( var.set_data( cg.StructInitializer( - DraytonData, - ("address", config[CONF_ADDRESS]), - ("channel", config[CONF_CHANNEL]), - ("command", config[CONF_COMMAND]), + DraytonData, ("address", config[CONF_ADDRESS]), ("data", data) ) ) ) @@ -1466,18 +1591,20 @@ def drayton_dumper(var, config): pass -@register_action("drayton", DraytonAction, DRAYTON_SCHEMA) -async def drayton_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "drayton", + "Drayton", + DRAYTON_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_DATA, "data", cg.uint8), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + validate=_validate_drayton_form, +) # RC5 -RC5Data, RC5BinarySensor, RC5Trigger, RC5Action, RC5Dumper = declare_protocol("RC5") +RC5Data, RC5BinarySensor, RC5Trigger, _, RC5Dumper = declare_protocol("RC5") RC5_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), @@ -1509,16 +1636,58 @@ def rc5_dumper(var, config): pass -@register_action("rc5", RC5Action, RC5_SCHEMA) -async def rc5_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc5", + "RC5", + RC5_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) + + +# RC5X (aka RC5 Marantz) +RC5XData, RC5XBinarySensor, RC5XTrigger, _, RC5XDumper = declare_protocol("RC5X") +RC5X_SCHEMA = RC5_SCHEMA.extend( + {cv.Required(CONF_EXTENSION): cv.hex_int_range(min=0, max=0x3F)} +) + + +@register_binary_sensor("rc5x", RC5XBinarySensor, RC5X_SCHEMA) +def rc5x_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + RC5XData, + ("address", config[CONF_ADDRESS]), + ("command", config[CONF_COMMAND]), + ("extension", config[CONF_EXTENSION]), + ) + ) + ) + + +@register_trigger("rc5x", RC5XTrigger, RC5XData) +def rc5x_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("rc5x", RC5XDumper) +def rc5x_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "rc5x", + "RC5X", + RC5X_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_EXTENSION, "extension", cg.uint8), +) # RC6 -RC6Data, RC6BinarySensor, RC6Trigger, RC6Action, RC6Dumper = declare_protocol("RC6") +RC6Data, RC6BinarySensor, RC6Trigger, _, RC6Dumper = declare_protocol("RC6") RC6_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint8_t, @@ -1552,12 +1721,17 @@ def rc6_dumper(var, config): pass -@register_action("rc6", RC6Action, RC6_SCHEMA) -async def rc6_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc6", + "RC6", + RC6_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + # Each action flips its own toggle bit, starting at 0: the lambda's static is per action + automation.ApplyCall( + "data.toggle = []() {{ static uint8_t toggle; return toggle++; }}()" + ), +) # RC Switch Raw @@ -1873,7 +2047,7 @@ def rc_switch_dumper(var, config): SamsungData, SamsungBinarySensor, SamsungTrigger, - SamsungAction, + _, SamsungDumper, ) = declare_protocol("Samsung") SAMSUNG_SCHEMA = cv.Schema( @@ -1907,12 +2081,13 @@ def samsung_dumper(var, config): pass -@register_action("samsung", SamsungAction, SAMSUNG_SCHEMA) -async def samsung_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint64) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "samsung", + "Samsung", + SAMSUNG_SCHEMA, + (CONF_DATA, "data", cg.uint64), + (CONF_NBITS, "nbits", cg.uint8), +) # Samsung36 @@ -1920,7 +2095,7 @@ async def samsung_action(var, config, args): Samsung36Data, Samsung36BinarySensor, Samsung36Trigger, - Samsung36Action, + _, Samsung36Dumper, ) = declare_protocol("Samsung36") SAMSUNG36_SCHEMA = cv.Schema( @@ -1954,12 +2129,13 @@ def samsung36_dumper(var, config): pass -@register_action("samsung36", Samsung36Action, SAMSUNG36_SCHEMA) -async def samsung36_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "samsung36", + "Samsung36", + SAMSUNG36_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint32), +) # Toshiba AC @@ -1967,7 +2143,7 @@ async def samsung36_action(var, config, args): ToshibaAcData, ToshibaAcBinarySensor, ToshibaAcTrigger, - ToshibaAcAction, + _, ToshibaAcDumper, ) = declare_protocol("ToshibaAc") TOSHIBAAC_SCHEMA = cv.Schema( @@ -2001,12 +2177,13 @@ def toshibaac_dumper(var, config): pass -@register_action("toshiba_ac", ToshibaAcAction, TOSHIBAAC_SCHEMA) -async def toshibaac_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint64) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint64) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "toshiba_ac", + "ToshibaAc", + TOSHIBAAC_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint64), + (CONF_RC_CODE_2, "rc_code_2", cg.uint64), +) # Panasonic @@ -2014,52 +2191,119 @@ async def toshibaac_action(var, config, args): PanasonicData, PanasonicBinarySensor, PanasonicTrigger, - PanasonicAction, + _, PanasonicDumper, ) = declare_protocol("Panasonic") PANASONIC_SCHEMA = cv.Schema( { + cv.Optional(CONF_NBITS, default=48): cv.one_of(48, 56, int=True), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_ADDRESS_2, default=0x00): cv.hex_uint8_t, cv.Required(CONF_COMMAND): cv.hex_uint32_t, } ) -@register_binary_sensor("panasonic", PanasonicBinarySensor, PANASONIC_SCHEMA) -def panasonic_binary_sensor(var, config): +def _panasonic_address_2_needs_56_bits(config: ConfigType) -> ConfigType: + # A 48-bit code has no second address byte; a lambda is only known on the device + address_2 = config[CONF_ADDRESS_2] + if config[CONF_NBITS] == 48 and isinstance(address_2, int) and address_2 != 0: + raise cv.Invalid( + f"{CONF_ADDRESS_2} is only sent with {CONF_NBITS}: 56", [CONF_ADDRESS_2] + ) + return config + + +# Transmit only +PANASONIC_ACTION_SCHEMA = PANASONIC_SCHEMA.extend( + { + cv.Optional(CONF_CARRIER_FREQUENCY, default="35000Hz"): cv.All( + cv.frequency, cv.uint32_t + ), + } +) + + +@register_binary_sensor( + "panasonic", + PanasonicBinarySensor, + cv.All(PANASONIC_SCHEMA, _panasonic_address_2_needs_56_bits), +) +def panasonic_binary_sensor(var: MockObj, config: ConfigType) -> None: cg.add( var.set_data( cg.StructInitializer( PanasonicData, ("address", config[CONF_ADDRESS]), ("command", config[CONF_COMMAND]), + ("nbits", config[CONF_NBITS]), + ("address_2", config[CONF_ADDRESS_2]), ) ) ) @register_trigger("panasonic", PanasonicTrigger, PanasonicData) -def panasonic_trigger(var, config): - pass +def panasonic_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" @register_dumper("panasonic", PanasonicDumper) -def panasonic_dumper(var, config): - pass +def panasonic_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" -@register_action("panasonic", PanasonicAction, PANASONIC_SCHEMA) -async def panasonic_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "panasonic", + "Panasonic", + PANASONIC_ACTION_SCHEMA, + (CONF_NBITS, "nbits", cg.uint8), + (CONF_ADDRESS, "address", cg.uint16), + (CONF_ADDRESS_2, "address_2", cg.uint8), + (CONF_COMMAND, "command", cg.uint32), + (CONF_CARRIER_FREQUENCY, "carrier_frequency", cg.uint32), + validate=_panasonic_address_2_needs_56_bits, +) +# SomfyRts +SOMFY_RTS_SCHEMA = cv.Schema( + { + cv.Required(CONF_KEY): cv.hex_uint8_t, + cv.Required(CONF_CONTROL): cv.All(cv.hex_uint8_t, cv.Range(max=0x0F)), + cv.Required(CONF_ROLLING_CODE): cv.hex_uint16_t, + cv.Required(CONF_ADDRESS): cv.All(cv.hex_uint32_t, cv.Range(max=0xFFFFFF)), + cv.Optional(CONF_CONTROL_IN_KEY, default=False): cv.boolean, + } +) + + +def _validate_somfy_rts(config: ConfigType) -> ConfigType: + key = config[CONF_KEY] + if config[CONF_CONTROL_IN_KEY] is True and isinstance(key, int) and key & 0x0F: + raise cv.Invalid( + f"With {CONF_CONTROL_IN_KEY} the low nibble of {CONF_KEY} carries {CONF_CONTROL} " + "and must be 0", + path=[CONF_KEY], + ) + return config + + +# Transmit only: the protocol has no decoder yet, so no binary sensor, trigger or dumper. +register_protocol_action( + "somfy_rts", + "SomfyRts", + SOMFY_RTS_SCHEMA, + (CONF_KEY, "key", cg.uint8), + (CONF_CONTROL, "control", cg.uint8), + (CONF_ROLLING_CODE, "rolling_code", cg.uint16), + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CONTROL_IN_KEY, "control_in_key", cg.bool_), + validate=_validate_somfy_rts, +) + # Nexa -NexaData, NexaBinarySensor, NexaTrigger, NexaAction, NexaDumper = declare_protocol( - "Nexa" -) +NexaData, NexaBinarySensor, NexaTrigger, _, NexaDumper = declare_protocol("Nexa") NEXA_SCHEMA = cv.Schema( { cv.Required(CONF_DEVICE): cv.hex_uint32_t, @@ -2097,20 +2341,132 @@ def nexa_dumper(var, config): pass -@register_action("nexa", NexaAction, NEXA_SCHEMA) -async def nexa_action(var, config, args): - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint32))) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.uint8))) - cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_level(await cg.templatable(config[CONF_LEVEL], args, cg.uint8))) +register_protocol_action( + "nexa", + "Nexa", + NEXA_SCHEMA, + (CONF_DEVICE, "device", cg.uint32), + (CONF_GROUP, "group", cg.uint8), + (CONF_STATE, "state", cg.uint8), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_LEVEL, "level", cg.uint8), +) + + +# Nexus +NexusData, NexusBinarySensor, NexusTrigger, _, NexusDumper = declare_protocol("Nexus") +NEXUS_SCHEMA = cv.Schema( + { + cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4), + cv.Required(CONF_ADDRESS): cv.hex_uint8_t, + } +) +NEXUS_ACTION_SCHEMA = NEXUS_SCHEMA.extend( + { + cv.Required(CONF_TEMPERATURE): cv.All( + cv.float_, cv.Range(min=-204.8, max=204.7) + ), + cv.Required(CONF_HUMIDITY): cv.uint8_t, + cv.Optional(CONF_BATTERY_LEVEL, default=True): cv.boolean, + cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, + } +) + + +@register_binary_sensor("nexus", NexusBinarySensor, NEXUS_SCHEMA) +def nexus_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + NexusData, + ("address", config[CONF_ADDRESS]), + ("channel", config[CONF_CHANNEL]), + ) + ) + ) + + +@register_trigger("nexus", NexusTrigger, NexusData) +def nexus_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("nexus", NexusDumper) +def nexus_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "nexus", + "Nexus", + NEXUS_ACTION_SCHEMA, + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_TEMPERATURE, "temperature", cg.float_), + (CONF_HUMIDITY, "humidity", cg.uint8), + (CONF_BATTERY_LEVEL, "battery_level", cg.bool_), + (CONF_FORCE_UPDATE, "force_update", cg.bool_), +) + + +# PD Pioneer +( + PDPioneerData, + PDPioneerBinarySensor, + PDPioneerTrigger, + _, + PDPioneerDumper, +) = declare_protocol("PDPioneer") + + +def _validate_pd_pioneer_burst(code: list[int]) -> list[int]: + """Byte 3 tells the odd (0x02) and even (0x01) bursts apart; decode() drops anything else.""" + if code[3] not in (0x01, 0x02): + raise cv.Invalid("Byte 3 of a pd_pioneer code must be 0x01 or 0x02") + return code + + +PDPIONEER_SCHEMA = cv.Schema( + { + cv.Required(CONF_CODE): cv.All( + [cv.hex_uint8_t], cv.Length(min=13, max=14), _validate_pd_pioneer_burst + ), + } +) + + +def _pd_pioneer_finalized(code: list[int]) -> list[int]: + """Fill in the checksum: the sum of the first 13 bytes, plus 15 for an odd burst.""" + checksum = sum(code[:13], 15 if code[3] == 0x02 else 0) & 0xFF + return [*code[:13], checksum] + + +@register_binary_sensor("pd_pioneer", PDPioneerBinarySensor, PDPIONEER_SCHEMA) +def pd_pioneer_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add(var.set_data(_pd_pioneer_finalized(config[CONF_CODE]))) + + +@register_trigger("pd_pioneer", PDPioneerTrigger, PDPioneerData) +def pd_pioneer_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("pd_pioneer", PDPioneerDumper) +def pd_pioneer_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "pd_pioneer", + "PDPioneer", + PDPIONEER_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PDPioneerData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # Midea -MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol( - "Midea" -) -MideaAction = ns.class_("MideaAction", RemoteTransmitterActionBase) +MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea") MIDEA_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=5, max=5)), @@ -2133,26 +2489,17 @@ def midea_dumper(var, config): pass -async def _code_bytes( - value: Any, args: TemplateArgsType, set_template: MockObj, set_static: MockObj -) -> None: - """Constant codes share one PROGMEM table pool across the remote protocols.""" - await automation.templatable_bytes( - value, args, set_template, set_static, "remote_base_code" - ) - - -@register_action("midea", MideaAction, MIDEA_SCHEMA) -async def midea_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "midea", + "Midea", + MIDEA_SCHEMA, + _bytes_field(CONF_CODE, f"data = {MideaData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # AEHA -AEHAData, AEHABinarySensor, AEHATrigger, AEHAAction, AEHADumper = declare_protocol( - "AEHA" -) +AEHAData, AEHABinarySensor, AEHATrigger, _, AEHADumper = declare_protocol("AEHA") AEHA_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, @@ -2184,9 +2531,9 @@ def aeha_dumper(var, config): pass -@register_action( +register_protocol_action( "aeha", - AEHAAction, + "AEHA", AEHA_SCHEMA.extend( { cv.Optional(CONF_CARRIER_FREQUENCY, default="38000Hz"): cv.All( @@ -2194,15 +2541,18 @@ def aeha_dumper(var, config): ), } ), + automation.ApplyField( + CONF_CARRIER_FREQUENCY, "get_data()->set_carrier_frequency({})", cg.uint32 + ), + automation.ApplyCall( + "encode({}, {})", + ( + (CONF_ADDRESS, cg.uint16), + (CONF_DATA, _BYTES, _code_table), + ), + ), + call="encode_call", ) -async def aeha_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - await _code_bytes( - config[CONF_DATA], args, var.set_data_template, var.set_data_static - ) - templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) - cg.add(var.set_carrier_frequency(templ)) # Hob2Hood @@ -2210,7 +2560,7 @@ async def aeha_action(var, config, args): Hob2HoodData, Hob2HoodBinarySensor, Hob2HoodTrigger, - Hob2HoodAction, + _, Hob2HoodDumper, ) = declare_protocol("Hob2Hood") @@ -2252,19 +2602,16 @@ def hob2hood_dumper(var: MockObj, config: ConfigType) -> None: """The dumper takes no options.""" -@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA) -async def hob2hood_action( - var: MockObj, config: ConfigType, args: TemplateArgsType -) -> None: - template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand) - cg.add(var.set_command(template_)) +register_protocol_action( + "hob2hood", + "Hob2Hood", + HOB2HOOD_SCHEMA, + (CONF_COMMAND, "command", Hob2HoodCommand), +) # Haier -HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( - "Haier" -) -HaierAction = ns.class_("HaierAction", RemoteTransmitterActionBase) +HaierData, HaierBinarySensor, HaierTrigger, _, HaierDumper = declare_protocol("Haier") HAIER_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All( @@ -2294,11 +2641,13 @@ def haier_dumper(var, config): pass -@register_action("haier", HaierAction, HAIER_SCHEMA) -async def haier_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "haier", + "Haier", + HAIER_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # ABBWelcome @@ -2306,7 +2655,7 @@ async def haier_action(var, config, args): ABBWelcomeData, ABBWelcomeBinarySensor, ABBWelcomeTrigger, - ABBWelcomeAction, + _, ABBWelcomeDumper, ) = declare_protocol("ABBWelcome") @@ -2358,49 +2707,28 @@ def abbwelcome_dumper(var, config): pass -@register_action("abbwelcome", ABBWelcomeAction, ABB_WELCOME_SCHEMA) -async def abbwelcome_action(var, config, args): - cg.add( - var.set_three_byte_address( - await cg.templatable(config[CONF_THREE_BYTE_ADDRESS], args, cg.bool_) - ) - ) - cg.add( - var.set_source_address( - await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_destination_address( - await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_retransmission( - await cg.templatable(config[CONF_RETRANSMISSION], args, cg.bool_) - ) - ) - cg.add( - var.set_message_type( - await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8) - ) - ) - template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_) - cg.add(var.set_auto_message_id(template_)) - if CONF_MESSAGE_ID in config: - cg.add( - var.set_message_id( - await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) - ) - ) - # TemplatableBytes starts in lambda mode with no function, so a transmit without data sets an empty payload - await automation.templatable_bytes( - config.get(CONF_DATA, []), - args, - var.set_data_template, - var.set_data_static, - "abbwelcome_data", - ) +register_protocol_action( + "abbwelcome", + "ABBWelcome", + ABB_WELCOME_SCHEMA, + # First: the address length decides where the other fields go + automation.ApplyField( + CONF_THREE_BYTE_ADDRESS, "data.set_three_byte_address({})", cg.bool_ + ), + automation.ApplyField( + CONF_SOURCE_ADDRESS, "data.set_source_address({})", cg.uint32 + ), + automation.ApplyField( + CONF_DESTINATION_ADDRESS, "data.set_destination_address({})", cg.uint32 + ), + automation.ApplyField(CONF_RETRANSMISSION, "data.set_retransmission({})", cg.bool_), + automation.ApplyField(CONF_MESSAGE_TYPE, "data.set_message_type({})", cg.uint8), + # Without message_id, finalize() picks one + automation.ApplyCall("data.auto_message_id = true"), + automation.ApplyField(CONF_MESSAGE_ID, "data.set_fixed_message_id({})", cg.uint8), + _bytes_field(CONF_DATA, "data.set_data({})"), + automation.ApplyCall("data.finalize()"), +) # Mirage @@ -2408,7 +2736,7 @@ async def abbwelcome_action(var, config, args): MirageData, MirageBinarySensor, MirageTrigger, - MirageAction, + _, MirageDumper, ) = declare_protocol("Mirage") @@ -2434,11 +2762,13 @@ def mirage_dumper(var, config): pass -@register_action("mirage", MirageAction, MIRAGE_SCHEMA) -async def mirage_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "mirage", + "Mirage", + MIRAGE_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # Toto @@ -2446,7 +2776,7 @@ async def mirage_action(var, config, args): TotoData, TotoBinarySensor, TotoTrigger, - TotoAction, + _, TotoDumper, ) = declare_protocol("Toto") @@ -2483,17 +2813,110 @@ def toto_dumper(var, config): pass -@register_action("toto", TotoAction, TOTO_SCHEMA) -async def Toto_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint8) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint8) - cg.add(var.set_rc_code_2(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - # Set toto-specific defaults (only if user didn't configure repeat) - if CONF_REPEAT not in config: - template_ = await cg.templatable(3, args, cg.uint32) - cg.add(var.set_send_times(template_)) - template_ = await cg.templatable(36000, args, cg.uint32) - cg.add(var.set_send_wait(template_)) +def _toto_default_repeat(config: ConfigType) -> ConfigType: + # Toto remotes send each frame 3 times, 36 ms apart + if CONF_REPEAT in config: + return config + return {**config, CONF_REPEAT: {CONF_TIMES: 3, CONF_WAIT_TIME: 36000}} + + +register_protocol_action( + "toto", + "Toto", + TOTO_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint8), + (CONF_RC_CODE_2, "rc_code_2", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + validate=_toto_default_repeat, +) + + +# IRA211 +( + IRA211Data, + IRA211BinarySensor, + IRA211Trigger, + _, + IRA211Dumper, +) = declare_protocol("IRA211") + +IRA211CommandEnum = ns.enum("IRA211Command", is_class=True) +IRA211_COMMANDS = { + "TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP, + "TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN, + "MODE": IRA211CommandEnum.IRA211_COMMAND_MODE, + "FAN": IRA211CommandEnum.IRA211_COMMAND_FAN, + "POWER": IRA211CommandEnum.IRA211_COMMAND_POWER, + "SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC, +} + +IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) +IRA211_MODES = { + "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, + "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, + "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, +} + +IRA211FanEnum = ns.enum("IRA211Fan", is_class=True) +IRA211_FAN_SPEEDS = { + "AUTO": IRA211FanEnum.IRA211_FAN_AUTO, + "LOW": IRA211FanEnum.IRA211_FAN_LOW, + "MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM, + "HIGH": IRA211FanEnum.IRA211_FAN_HIGH, +} + + +def _half_degree_step(value: float) -> float: + if value * 2 != int(value * 2): + raise cv.Invalid("Temperature must be a multiple of 0.5") + return value + + +IRA211_SCHEMA = cv.Schema( + { + cv.Required(CONF_COMMAND): cv.enum(IRA211_COMMANDS, upper=True), + cv.Optional(CONF_TEMPERATURE, default=20): cv.All( + cv.float_range(min=5, max=35), _half_degree_step + ), + cv.Optional(CONF_MODE, default="COMFORT"): cv.enum(IRA211_MODES, upper=True), + cv.Optional(CONF_FAN_MODE, default="AUTO"): cv.enum( + IRA211_FAN_SPEEDS, upper=True + ), + } +) + + +@register_binary_sensor("ira211", IRA211BinarySensor, IRA211_SCHEMA) +def ira211_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + IRA211Data, + ("command", config[CONF_COMMAND]), + ("half_degrees", int(config[CONF_TEMPERATURE] * 2)), + ("mode", config[CONF_MODE]), + ("fan", config[CONF_FAN_MODE]), + ) + ) + ) + + +@register_trigger("ira211", IRA211Trigger, IRA211Data) +def ira211_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("ira211", IRA211Dumper) +def ira211_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "ira211", + "IRA211", + IRA211_SCHEMA, + (CONF_COMMAND, "command", IRA211CommandEnum), + automation.ApplyField(CONF_TEMPERATURE, "data.set_temperature({})", cg.float_), + (CONF_MODE, "mode", IRA211ModeEnum), + (CONF_FAN_MODE, "fan", IRA211FanEnum), +) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 901950d363..ecd27a95a5 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" #include @@ -111,13 +112,19 @@ class ABBWelcomeData { return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } + /// A configured id, which finalize() keeps instead of picking one + void set_fixed_message_id(uint8_t message_id) { + this->set_message_id(message_id); + this->auto_message_id = false; + } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } + /// data may be a PROGMEM table void set_data(const uint8_t *data, size_t len) { uint8_t size = static_cast(std::min(MAX_DATA_LENGTH, len)); this->data_[2] &= (0xff ^ DATA_LENGTH_MASK); this->data_[2] |= (size & DATA_LENGTH_MASK); if (size) - std::copy_n(data, size, this->data_.begin() + 5 + 2 * this->get_address_length()); + progmem_memcpy(this->data_.data() + 5 + 2 * this->get_address_length(), data, size); } void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { @@ -226,30 +233,4 @@ class ABBWelcomeBinarySensor : public RemoteReceiverBinarySensorBase { using ABBWelcomeTrigger = RemoteReceiverTrigger; using ABBWelcomeDumper = RemoteReceiverDumper; -template class ABBWelcomeAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, source_address) - TEMPLATABLE_VALUE(uint32_t, destination_address) - TEMPLATABLE_VALUE(bool, retransmission) - TEMPLATABLE_VALUE(bool, three_byte_address) - TEMPLATABLE_VALUE(uint8_t, message_type) - TEMPLATABLE_VALUE(uint8_t, message_id) - TEMPLATABLE_VALUE(bool, auto_message_id) - TEMPLATABLE_BYTES(data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ABBWelcomeData data; - data.set_three_byte_address(this->three_byte_address_.value(x...)); - data.set_source_address(this->source_address_.value(x...)); - data.set_destination_address(this->destination_address_.value(x...)); - data.set_retransmission(this->retransmission_.value(x...)); - data.set_message_type(this->message_type_.value(x...)); - data.set_message_id(this->message_id_.value(x...)); - data.auto_message_id = this->auto_message_id_.value(x...); - this->data_.template visit( - [&data](const uint8_t *bytes, size_t len) { data.set_data(bytes, len); }, x...); - data.finalize(); - ABBWelcomeProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index 3263f6b220..70454dbc49 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -1,4 +1,5 @@ #include "aeha_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -28,7 +29,7 @@ void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8 } for (size_t i = 0; i < len; i++) { - const uint8_t bit = data[i]; + const uint8_t bit = progmem_read_byte(data + i); for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { if (bit & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index f6064dd5df..ab762612e1 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -18,7 +18,11 @@ class AEHAProtocol : public RemoteProtocol { void encode(RemoteTransmitData *dst, const AEHAData &data) { this->encode(dst, data.address, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, uint16_t address, const std::vector &data) { + this->encode(dst, address, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -28,18 +32,4 @@ class AEHAProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(AEHA) -template class AEHAAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_BYTES(data) - TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - - void encode(RemoteTransmitData *dst, Ts... x) override { - const uint16_t address = this->address_.value(x...); - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - this->data_.visit( - [dst, address](const uint8_t *data, size_t len) { AEHAProtocol().encode(dst, address, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index ed9d6aa671..539aa0d75b 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -23,19 +23,4 @@ class Beo4Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Beo4) -template class Beo4Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, source) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Beo4Data data{}; - data.source = this->source_.value(x...); - data.command = this->command_.value(x...); - data.repeats = this->repeats_.value(x...); - Beo4Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index bfea463b7d..f85df95305 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -20,15 +20,4 @@ class BrennenstuhlProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Brennenstuhl) -template class BrennenstuhlAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - BrennenstuhlData data{}; - data.code = this->code_.value(x...); - BrennenstuhlProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index c71390c267..67a2d5a06a 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -28,17 +28,4 @@ class ByronSXProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ByronSX) -template class ByronSXAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ByronSXData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - ByronSXProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 09bead18b3..31e4f5c47e 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -41,36 +41,6 @@ class CanalSatLDProtocol : public CanalSatBaseProtocol { DECLARE_REMOTE_PROTOCOL(CanalSat) -template class CanalSatAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatProtocol().encode(dst, data); - } -}; - DECLARE_REMOTE_PROTOCOL(CanalSatLD) -template class CanalSatLDAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatLDProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index 29a306ce29..d5ce29d436 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -28,12 +28,4 @@ class CoolixProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Coolix) -template class CoolixAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, first) - TEMPLATABLE_VALUE(uint32_t, second) - void encode(RemoteTransmitData *dst, Ts... x) override { - CoolixProtocol().encode(dst, {this->first_.value(x...), this->second_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index f319b55f43..85ea1faa38 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -20,17 +20,4 @@ class DishProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dish) -template class DishAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DishData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - DishProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 954c3cf1d3..06f77f8050 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -27,21 +27,4 @@ class DooyaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dooya) -template class DooyaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, id) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, button) - TEMPLATABLE_VALUE(uint8_t, check) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DooyaData data{}; - data.id = this->id_.value(x...); - data.channel = this->channel_.value(x...); - data.button = this->button_.value(x...); - data.check = this->check_.value(x...); - DooyaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.cpp b/esphome/components/remote_base/drayton_protocol.cpp index 598f1a476e..1bb2a68417 100644 --- a/esphome/components/remote_base/drayton_protocol.cpp +++ b/esphome/components/remote_base/drayton_protocol.cpp @@ -12,13 +12,12 @@ static constexpr uint8_t CARRIER_KHZ = 2; static constexpr uint8_t NBITS_PREAMBLE = 12; static constexpr uint8_t NBITS_SYNC = 4; static constexpr uint8_t NBITS_ADDRESS = 16; -static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_DATA = 4; +static constexpr uint8_t NBITS_CHECKSUM = 8; static constexpr uint8_t NBITS_COMMAND = 7; -static constexpr uint8_t NDATABITS = NBITS_ADDRESS + NBITS_CHANNEL + NBITS_COMMAND; -static constexpr uint8_t MIN_RX_SRC = (NDATABITS + NBITS_SYNC / 2); - -static constexpr uint8_t CMD_ON = 0x41; -static constexpr uint8_t CMD_OFF = 0x02; +static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_PKT = NBITS_ADDRESS + NBITS_DATA + NBITS_CHECKSUM; +static constexpr uint8_t MIN_RX_SRC = (NBITS_PKT + NBITS_SYNC / 2); /* Drayton Protocol @@ -31,59 +30,72 @@ The number of these bits seen at the receiver varies depending on the state of the bias when the packet transmission starts. The receiver algoritmn takes account of this. -The packet appears to be Manchester encoded, with a '10' tranmitted pair -representing a '1' bit and a '01' pair representing a '0' bit. Each packet is -begun with a '1100' syncronisation symbol which breaks this rule. Following +The packet is Manchester coded, with a '10' tranmitted pair representing +a '1' bit and a '01' pair representing a '0' bit. Each packet is begun +with a '1100' syncronisation symbol which breaks this rule. Following the sync are 28 '01' or '10' pairs. -------------------- -Boiler On Command as received: +Sample Boiler On Command as received: 101010101010110001101001010101101001010101010101100101010101101001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-1-0-0-0-0-0-1-1-0-0-1-0 (Where pppp represents the preamble bits and SSSS represents the sync symbol) -28 bits of data received 01100001100000001000001 10010 (bin) or 6180832 (hex) +28 bits received 01100001100000001000001 10010 (bin) or 6180832 (hex) -Boiler Off Command as received: +Sample Boiler Off Command as received: 101010101010110001101001010101101001010101010101010101010110011001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-0-0-0-0-0-1-0-1-0-0-1-0 -28 bits of data received 0110000110000000000001010010 (bin) or 6180052 (hex) +28 bits received 0110000110000000000001010010 (bin) or 6180052 (hex) -------------------- -I have used 'RFLink' software (RLink Firmware Version: 1.1 Revision: 48) to -capture and retransmit the Digistat packets. RFLink splits each packet into an -ID, SWITCH, and CMD field. +The 28 bits consist of 20 bits of payload and 8 bits of checksum. -0;17;Drayton;ID=c300;SWITCH=12;CMD=ON; -20;18;Drayton;ID=c300;SWITCH=12;CMD=OFF; +In order of transmission the payload consists of the following fields: --------------------- +Bits Description +12 ID of the thermostat. A Digistat receiver will only respond to a packet if its ID + matches the ID that the receiver has previously learnt. +4 Pairing field, normally 0000. The first packets sent by the thermostat after its + batteries are inserted have this field set to 0001, which lets a Digistat receiver + in pairing mode learn the ID of the thermostat. +1 Thermostat on (1) or off (0). +1 Always 0. +1 Low battery (1) or battery OK (0). +1 Always 0. -Spliting my received data into three parts of 16, 7 and 5 bits gives address, -channel and Command values of: +The YAML/lambda 'address' is the first 16 bits as sent (the 12 bit ID followed by the +pairing field, so the pairing flag is the address LSB), and 'data' is the next 4 bits +(0x8 = on, 0x2 = low battery). -On 6180832 0110000110000000 1000001 10010 -address: '0x6180' channel: '0x12' command: '0x41' +Checksum: see calc_cs(); the +1 when the sum is above 0x7F is empirical. The captured +packets above give 0x32 (On) and 0x52 (Off). Packets that fail it are logged at verbose level. -Off 6180052 0110000110000000 0000010 10010 -address: '0x6180' channel: '0x12' command: '0x02' - -These values are slightly different to those used by RFLink (the RFLink -ID/Adress value is rotated/manipulated), and I don't know who's interpretation -is correct. A larger data sample would help (I have only found five different -packet captures online) or definitive information from Drayton. - -Splitting each packet in this way works well for me with esphome. Any -corrections or additional data samples would be gratefully received. - -marshn +Legacy split (from RFLink): address (16), command (7: data + top 3 checksum bits), channel (5: low checksum bits). +When channel and command are set they are sent as is, with no checksum calculated. */ +static uint8_t calc_cs(uint32_t out_data) { + uint16_t cs = 0; + uint32_t rev = reverse_bits(out_data); + + for (uint8_t i = 4; i <= 20; i += 4) { + cs <<= 1; + cs += (rev >> i) & 0x0F; + } + + if (cs > 0x7F) { + cs += 1; + } + // Sent LSB first + return reverse_bits(uint8_t(cs << 1)); +} + void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { uint16_t khz = CARRIER_KHZ; dst->set_carrier_frequency(khz * 1000); @@ -108,17 +120,21 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } } - ESP_LOGD(TAG, "Send Drayton: address=%04x channel=%03x cmd=%02x", data.address, data.channel, data.command); + if (data.channel || data.command) { + out_data = data.address; + out_data <<= NBITS_COMMAND; + out_data |= data.command; + out_data <<= NBITS_CHANNEL; + out_data |= data.channel; + } else { + out_data = data.address << (NBITS_DATA + NBITS_CHECKSUM); + out_data |= (data.data & ((1u << NBITS_DATA) - 1)) << NBITS_CHECKSUM; // a lambda may exceed 4 bits + out_data |= calc_cs(out_data); + } + ESP_LOGD(TAG, "addr=%04x data=%x channel=%02x command=%02x", data.address, data.data, data.channel, data.command); + ESP_LOGV(TAG, "out_data %07" PRIx32, out_data); - out_data = data.address; - out_data <<= NBITS_COMMAND; - out_data |= data.command; - out_data <<= NBITS_CHANNEL; - out_data |= data.channel; - - ESP_LOGV(TAG, "Send Drayton: out_data %08" PRIx32, out_data); - - for (uint32_t mask = 1UL << (NDATABITS - 1); mask != 0; mask >>= 1) { + for (uint32_t mask = 1UL << (NBITS_PKT - 1); mask != 0; mask >>= 1) { if (out_data & mask) { dst->mark(BIT_TIME_US); dst->space(BIT_TIME_US); @@ -130,21 +146,9 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } optional DraytonProtocol::decode(RemoteReceiveData src) { - DraytonData out{ - .address = 0, - .channel = 0, - .command = 0, - }; + DraytonData out{}; while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, - "Decode Drayton: %" PRId32 ", %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 "", - src.size() - src.get_index(), src.peek(0), src.peek(1), src.peek(2), src.peek(3), src.peek(4), - src.peek(5), src.peek(6), src.peek(7), src.peek(8), src.peek(9), src.peek(10), src.peek(11), src.peek(12), - src.peek(13), src.peek(14), src.peek(15), src.peek(16), src.peek(17), src.peek(18), src.peek(19)); - // If first preamble item is a space, skip it if (src.peek_space_at_least(1)) { src.advance(1); @@ -152,12 +156,12 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Look for sync pulse, after. If sucessful index points to space of sync symbol while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, "Decode Drayton: sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), - src.peek(), src.peek(1)); + ESP_LOGVV(TAG, "sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), src.peek(), + src.peek(1)); if (src.peek_mark(2 * BIT_TIME_US) && (src.peek_space(2 * BIT_TIME_US, 1) || src.peek_space(3 * BIT_TIME_US, 1))) { src.advance(1); - ESP_LOGVV(TAG, "Decode Drayton: Found SYNC, - %" PRIu32, src.get_index()); + ESP_LOGVV(TAG, "Found SYNC %" PRIu32, src.get_index()); break; } else { src.advance(2); @@ -165,8 +169,8 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } // No point continuing if not enough samples remaining to complete a packet - if (src.size() - src.get_index() < NDATABITS) { - ESP_LOGV(TAG, "Decode Drayton: Fail 1, - %" PRIu32, src.get_index()); + if (src.size() - src.get_index() < NBITS_PKT) { + ESP_LOGV(TAG, "Fail1 %" PRIu32, src.get_index()); break; } @@ -174,17 +178,14 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Extract first bit // Checks next bit to leave index pointing correctly uint32_t out_data = 0; - uint8_t bit = NDATABITS - 1; - ESP_LOGVV(TAG, "Decode Drayton: first bit %" PRId32 " %" PRId32 ", %" PRId32, src.peek(0), src.peek(1), - src.peek(2)); + uint8_t bit = NBITS_PKT - 1; if (src.expect_space(3 * BIT_TIME_US) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; } else if (src.expect_space(2 * BIT_TIME_US) && src.expect_mark(BIT_TIME_US) && (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { out_data |= 1 << bit; } else { - ESP_LOGV(TAG, "Decode Drayton: Fail 2, - %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail2 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -192,7 +193,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // if there is no transition at the start of the bit period, then the transition in the middle of // the previous bit period. while (--bit >= 1) { - ESP_LOGVV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + ESP_LOGVV(TAG, "Data %2d %08" PRIx32, bit, out_data); if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; @@ -205,8 +206,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } if (bit > 0) { - ESP_LOGVV(TAG, "Decode Drayton: Fail 3, %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail3 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -218,21 +218,28 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { continue; } - ESP_LOGV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + uint8_t cs = out_data & 0xFF; + uint8_t expected_cs = calc_cs(out_data); - out.channel = (uint8_t) (out_data & 0x1F); - out_data >>= NBITS_CHANNEL; - out.command = (uint8_t) (out_data & 0x7F); - out_data >>= NBITS_COMMAND; - out.address = (uint16_t) (out_data & 0xFFFF); + if (cs != expected_cs) { + ESP_LOGV(TAG, "Dropped %07" PRIx32 ": checksum 0x%02x, expected 0x%02x", out_data, cs, expected_cs); + continue; + } + + ESP_LOGV(TAG, "Data %07" PRIx32, out_data); + + out.data = (out_data >> NBITS_CHECKSUM) & ((1u << NBITS_DATA) - 1); + out.address = out_data >> (NBITS_CHECKSUM + NBITS_DATA); + out.channel = out_data & ((1u << NBITS_CHANNEL) - 1); + out.command = (out_data >> NBITS_CHANNEL) & ((1u << NBITS_COMMAND) - 1); return out; } return {}; } + void DraytonProtocol::dump(const DraytonData &data) { - ESP_LOGI(TAG, "Received Drayton: address=0x%04X (0x%04x), channel=0x%03x command=0x%03X", data.address, - ((data.address << 1) & 0xffff), data.channel, data.command); + ESP_LOGI(TAG, "Received Drayton: address=0x%04X, data=0x%X", data.address, data.data); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 4e879f0f75..83988fbaa6 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -11,10 +11,10 @@ struct DraytonData { uint16_t address; uint8_t channel; uint8_t command; + uint8_t data; // last, so positional initializers written for the legacy layout keep their meaning - bool operator==(const DraytonData &rhs) const { - return address == rhs.address && channel == rhs.channel && command == rhs.command; - } + // channel/command: legacy split of the same bits, filled on decode, ignored by operator== + bool operator==(const DraytonData &rhs) const { return address == rhs.address && data == rhs.data; } }; class DraytonProtocol : public RemoteProtocol { @@ -26,19 +26,4 @@ class DraytonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Drayton) -template class DraytonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DraytonData data{}; - data.address = this->address_.value(x...); - data.channel = this->channel_.value(x...); - data.command = this->command_.value(x...); - DraytonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 663e50fb4b..5afa28e07b 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -28,17 +28,4 @@ class DysonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dyson) -template class DysonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, code) - TEMPLATABLE_VALUE(uint8_t, index) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DysonData data{}; - data.code = this->code_.value(x...); - data.index = this->index_.value(x...); - DysonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 0c8797af70..55e8137b75 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -38,15 +38,4 @@ class GoboxProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Gobox) -template class GoboxAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, code); - - void encode(RemoteTransmitData *dst, Ts... x) override { - GoboxData data{}; - data.code = this->code_.value(x...); - GoboxProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index e7fa563fb5..07e3793520 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -1,4 +1,5 @@ #include "haier_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -39,7 +40,7 @@ void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t dst->mark(BIT_MARK_US); uint8_t checksum = 0; for (size_t i = 0; i < len; i++) { - const uint8_t item = data[i]; + const uint8_t item = progmem_read_byte(data + i); this->encode_byte_(dst, item); checksum += item; } diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 770784b0fc..0594049471 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -14,7 +14,11 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -24,13 +28,4 @@ class HaierProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Haier) -template class HaierAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - this->code_.visit([dst](const uint8_t *data, size_t len) { HaierProtocol().encode(dst, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h index 44bd873a65..dd3288103c 100644 --- a/esphome/components/remote_base/hob2hood_protocol.h +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -28,12 +28,4 @@ class Hob2HoodProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Hob2Hood) -template class Hob2HoodAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(Hob2HoodCommand, command) - void encode(RemoteTransmitData *dst, Ts... x) override { - Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/ira211_protocol.cpp b/esphome/components/remote_base/ira211_protocol.cpp new file mode 100644 index 0000000000..b9cd6ec886 --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.cpp @@ -0,0 +1,292 @@ +#include "ira211_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.ira211"); + +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint32_t T_US = 800; +static constexpr uint32_t HEADER_US = 19 * T_US / 2; // 7600 us, also the gap that separates frames +// Midway between the longest data run (9 bits, 7200 us) and the gap, so a gap captured a little short still +// ends the frame +static constexpr uint32_t GAP_MIN_US = HEADER_US - T_US / 4; +static constexpr uint8_t MIN_HALF_DEGREES = static_cast(IRA211_MIN_TEMPERATURE * 2); +static constexpr uint8_t MAX_HALF_DEGREES = static_cast(IRA211_MAX_TEMPERATURE * 2); + +static constexpr uint8_t DEVICE_ID = 0x66; +static constexpr uint8_t BITS_PER_PACKET = 10; +static constexpr uint8_t MIN_PACKETS = 4; +static constexpr uint8_t MAX_PACKETS = 7; +static constexpr uint8_t MAX_BITS = MAX_PACKETS * BITS_PER_PACKET; + +// Fields each command carries, in wire order after the command byte: temperature (whole degrees +// then tenths), mode, fan. +static constexpr uint8_t FIELD_TEMPERATURE = 1 << 0; +static constexpr uint8_t FIELD_MODE = 1 << 1; +static constexpr uint8_t FIELD_FAN = 1 << 2; + +struct CommandLayout { + IRA211Command command; + uint8_t fields; +}; + +static const CommandLayout LAYOUTS[] PROGMEM = { + {IRA211Command::IRA211_COMMAND_TEMP_UP, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_TEMP_DOWN, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_MODE, FIELD_MODE}, + {IRA211Command::IRA211_COMMAND_FAN, FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_POWER, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_SYNC, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, +}; + +static optional find_layout(IRA211Command command) { + for (const auto &slot : LAYOUTS) { + CommandLayout layout; + progmem_memcpy(&layout, &slot, sizeof(layout)); + if (layout.command == command) + return layout; + } + return {}; +} + +// Device ID, command, fields, checksum +static uint8_t packet_count(const CommandLayout &layout) { + return 3 + ((layout.fields & FIELD_TEMPERATURE) ? 2 : 0) + ((layout.fields & FIELD_MODE) ? 1 : 0) + + ((layout.fields & FIELD_FAN) ? 1 : 0); +} + +// Value to wire byte and back; the transform is its own inverse +static uint8_t wire_xform(uint8_t value) { return reverse_bits(static_cast(~value)); } + +static bool is_known_mode(uint8_t value) { + switch (static_cast(value)) { + case IRA211Mode::IRA211_MODE_PROTECTION: + case IRA211Mode::IRA211_MODE_TIMER: + case IRA211Mode::IRA211_MODE_COMFORT: + return true; + default: + return false; + } +} + +static bool is_known_fan(uint8_t value) { + switch (static_cast(value)) { + case IRA211Fan::IRA211_FAN_AUTO: + case IRA211Fan::IRA211_FAN_LOW: + case IRA211Fan::IRA211_FAN_MEDIUM: + case IRA211Fan::IRA211_FAN_HIGH: + return true; + default: + return false; + } +} + +static uint8_t checksum(const uint8_t *bytes, uint8_t count) { + uint8_t sum = count; + for (uint8_t i = 0; i < count; i++) + sum += reverse_bits(bytes[i]); + return reverse_bits(sum); +} + +bool IRA211Data::operator==(const IRA211Data &rhs) const { + if (this->command != rhs.command) + return false; + const optional layout = find_layout(this->command); + if (!layout.has_value()) + return true; + if ((layout->fields & FIELD_TEMPERATURE) && this->half_degrees != rhs.half_degrees) + return false; + if ((layout->fields & FIELD_MODE) && this->mode != rhs.mode) + return false; + return !(layout->fields & FIELD_FAN) || this->fan == rhs.fan; +} + +void IRA211Protocol::encode(RemoteTransmitData *dst, const IRA211Data &data) { + const optional layout = find_layout(data.command); + if (!layout.has_value()) + return; + + uint8_t bytes[MAX_PACKETS]; + uint8_t count = 0; + bytes[count++] = DEVICE_ID; + bytes[count++] = wire_xform(static_cast(data.command)); + if (layout->fields & FIELD_TEMPERATURE) { + bytes[count++] = wire_xform(data.half_degrees / 2); + bytes[count++] = wire_xform((data.half_degrees & 1) ? 5 : 0); + } + if (layout->fields & FIELD_MODE) + bytes[count++] = wire_xform(static_cast(data.mode)); + if (layout->fields & FIELD_FAN) + bytes[count++] = wire_xform(static_cast(data.fan)); + bytes[count] = checksum(bytes, count); + count++; + + dst->set_carrier_frequency(CARRIER_FREQUENCY); + // Preamble, at most one item per bit, trailing mark and gap + dst->reserve(4 + count * BITS_PER_PACKET + 2); + dst->item(HEADER_US, T_US); + dst->item(T_US, HEADER_US); + + // Each packet is a 1 bit, the byte MSB first, then a 0 bit; runs of equal bits become one mark or space + bool level = true; + uint32_t run = 0; + for (uint8_t p = 0; p < count; p++) { + const uint16_t packet = (1 << 9) | (bytes[p] << 1); + for (int8_t i = BITS_PER_PACKET - 1; i >= 0; i--) { + const bool bit = (packet >> i) & 1; + if (bit != level && run != 0) { + if (level) { + dst->mark(run * T_US); + } else { + dst->space(run * T_US); + } + run = 0; + } + level = bit; + run++; + } + } + // Every packet ends with a 0 bit, so the frame ends with a space, closed by a trailing mark + dst->space(run * T_US); + dst->mark(T_US); + dst->space(HEADER_US); +} + +optional IRA211Protocol::decode(RemoteReceiveData src) { + if (!src.expect_item(HEADER_US, T_US) || !src.expect_item(T_US, HEADER_US)) + return {}; + + // Read bits until the end-of-frame gap, checking packet framing and collecting bytes as they arrive + uint8_t bytes[MAX_PACKETS]{}; + uint8_t bits = 0; + while (src.is_valid()) { + const int32_t raw = src.peek(); + const bool is_mark = raw > 0; + const uint32_t duration = is_mark ? raw : -raw; + // A mark followed by the gap or the end of the data is the trailing mark, not a data bit + if (is_mark && (!src.is_valid(1) || src.peek(1) <= -static_cast(GAP_MIN_US))) + break; + if (!is_mark && duration >= GAP_MIN_US) + break; + const uint32_t run = (duration + T_US / 2) / T_US; + if (run == 0 || bits + run > MAX_BITS) + return {}; + for (uint32_t i = 0; i < run; i++, bits++) { + const uint8_t pos = bits % BITS_PER_PACKET; + if ((pos == 0 && !is_mark) || (pos == BITS_PER_PACKET - 1 && is_mark)) + return {}; + if (pos != 0 && pos != BITS_PER_PACKET - 1) { + uint8_t &byte = bytes[bits / BITS_PER_PACKET]; + byte = (byte << 1) | (is_mark ? 1 : 0); + } + } + src.advance(); + } + + const uint8_t count = bits / BITS_PER_PACKET; + if (bits % BITS_PER_PACKET != 0 || count < MIN_PACKETS || bytes[0] != DEVICE_ID) + return {}; + IRA211Data out{}; + out.command = static_cast(wire_xform(bytes[1])); + const optional layout = find_layout(out.command); + if (!layout.has_value() || packet_count(*layout) != count || checksum(bytes, count - 1) != bytes[count - 1]) { + ESP_LOGV(TAG, "Invalid frame (%u bits)", bits); + return {}; + } + + // A checksum-valid frame can still carry values outside what the thermostat accepts + uint8_t index = 2; + if (layout->fields & FIELD_TEMPERATURE) { + const uint8_t whole = wire_xform(bytes[index++]); + const uint8_t tenths = wire_xform(bytes[index++]); + const uint16_t half_degrees = whole * 2 + (tenths >= 5 ? 1 : 0); + if (half_degrees < MIN_HALF_DEGREES || half_degrees > MAX_HALF_DEGREES) + return {}; + out.half_degrees = static_cast(half_degrees); + } + if (layout->fields & FIELD_MODE) { + const uint8_t mode = wire_xform(bytes[index++]); + if (!is_known_mode(mode)) + return {}; + out.mode = static_cast(mode); + } + if (layout->fields & FIELD_FAN) { + const uint8_t fan = wire_xform(bytes[index++]); + if (!is_known_fan(fan)) + return {}; + out.fan = static_cast(fan); + } + return out; +} + +static const LogString *command_to_string(IRA211Command command) { + switch (command) { + case IRA211Command::IRA211_COMMAND_TEMP_UP: + return LOG_STR("TEMP_UP"); + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + return LOG_STR("TEMP_DOWN"); + case IRA211Command::IRA211_COMMAND_MODE: + return LOG_STR("MODE"); + case IRA211Command::IRA211_COMMAND_FAN: + return LOG_STR("FAN"); + case IRA211Command::IRA211_COMMAND_POWER: + return LOG_STR("POWER"); + case IRA211Command::IRA211_COMMAND_SYNC: + return LOG_STR("SYNC"); + default: + return LOG_STR("UNKNOWN"); + } +} + +static const LogString *mode_to_string(IRA211Mode mode) { + switch (mode) { + case IRA211Mode::IRA211_MODE_PROTECTION: + return LOG_STR("Protection"); + case IRA211Mode::IRA211_MODE_TIMER: + return LOG_STR("Timer"); + case IRA211Mode::IRA211_MODE_COMFORT: + return LOG_STR("Comfort"); + default: + return LOG_STR("Unknown"); + } +} + +static const LogString *fan_to_string(IRA211Fan fan) { + switch (fan) { + case IRA211Fan::IRA211_FAN_AUTO: + return LOG_STR("Auto"); + case IRA211Fan::IRA211_FAN_LOW: + return LOG_STR("1/3"); + case IRA211Fan::IRA211_FAN_MEDIUM: + return LOG_STR("2/3"); + case IRA211Fan::IRA211_FAN_HIGH: + return LOG_STR("3/3"); + default: + return LOG_STR("Unknown"); + } +} + +void IRA211Protocol::dump(const IRA211Data &data) { + const optional layout = find_layout(data.command); + const uint8_t fields = layout.has_value() ? layout->fields : 0; + const unsigned whole = data.half_degrees / 2; + const unsigned tenths = (data.half_degrees & 1) ? 5 : 0; + const auto *command = LOG_STR_ARG(command_to_string(data.command)); + if (fields == (FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN)) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC, mode=%s, fan=%s", command, whole, tenths, + LOG_STR_ARG(mode_to_string(data.mode)), LOG_STR_ARG(fan_to_string(data.fan))); + } else if (fields == FIELD_TEMPERATURE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC", command, whole, tenths); + } else if (fields == FIELD_MODE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, mode=%s", command, LOG_STR_ARG(mode_to_string(data.mode))); + } else if (fields == FIELD_FAN) { + ESP_LOGI(TAG, "Received IRA211: command=%s, fan=%s", command, LOG_STR_ARG(fan_to_string(data.fan))); + } else { + ESP_LOGI(TAG, "Received IRA211: command=%s", command); + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/ira211_protocol.h b/esphome/components/remote_base/ira211_protocol.h new file mode 100644 index 0000000000..205d09215a --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.h @@ -0,0 +1,65 @@ +#pragma once + +#include "remote_base.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::remote_base { + +/* Siemens IRA211 thermostat remote: 38 kHz carrier, NRZ runs of 800 us bits after a fixed preamble. + A frame is 4, 5 or 7 ten-bit packets (start bit 1, 8 data bits, end bit 0): device ID, command, + the command's fields, then a checksum. Field bytes go over the wire as reverse_bits(~value). */ + +enum class IRA211Command : uint8_t { + IRA211_COMMAND_TEMP_UP = 1, + IRA211_COMMAND_TEMP_DOWN = 2, + IRA211_COMMAND_MODE = 4, + IRA211_COMMAND_FAN = 5, + IRA211_COMMAND_POWER = 6, + IRA211_COMMAND_SYNC = 7, +}; + +enum class IRA211Mode : uint8_t { + IRA211_MODE_PROTECTION = 0, + IRA211_MODE_TIMER = 85, + IRA211_MODE_COMFORT = 170, +}; + +enum class IRA211Fan : uint8_t { + IRA211_FAN_AUTO = 3, + IRA211_FAN_LOW = 12, + IRA211_FAN_MEDIUM = 48, + IRA211_FAN_HIGH = 192, +}; + +static constexpr float IRA211_MIN_TEMPERATURE = 5.0f; +static constexpr float IRA211_MAX_TEMPERATURE = 35.0f; + +struct IRA211Data { + IRA211Command command{IRA211Command::IRA211_COMMAND_SYNC}; + uint8_t half_degrees{40}; // target temperature in 0.5 C steps + IRA211Mode mode{IRA211Mode::IRA211_MODE_COMFORT}; + IRA211Fan fan{IRA211Fan::IRA211_FAN_AUTO}; + + float get_temperature() const { return this->half_degrees * 0.5f; } + /// Rounds to the nearest half degree and clamps to the 5 to 35 C range the thermostat accepts. + void set_temperature(float temperature) { + this->half_degrees = + static_cast(lroundf(clamp(temperature, IRA211_MIN_TEMPERATURE, IRA211_MAX_TEMPERATURE) * 2.0f)); + } + + /// Compares the command and only the fields that command carries. + bool operator==(const IRA211Data &rhs) const; +}; + +class IRA211Protocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const IRA211Data &data); + optional decode(RemoteReceiveData src); + void dump(const IRA211Data &data); +}; + +DECLARE_REMOTE_PROTOCOL(IRA211) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index 5911664fc3..58991fc8f4 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -21,15 +21,4 @@ class JVCProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(JVC) -template class JVCAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - JVCData data{}; - data.data = this->data_.value(x...); - JVCProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 093f2904aa..e391212e42 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -32,25 +32,4 @@ class KeeloqProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Keeloq) -template class KeeloqAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, address) - TEMPLATABLE_VALUE(uint32_t, encrypted) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(bool, vlow) - TEMPLATABLE_VALUE(uint16_t, suffix) - TEMPLATABLE_VALUE(uint8_t, suffix_bits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - KeeloqData data{}; - data.address = this->address_.value(x...); - data.encrypted = this->encrypted_.value(x...); - data.command = this->command_.value(x...); - data.vlow = this->vlow_.value(x...); - data.suffix = this->suffix_.value(x...); - data.suffix_bits = this->suffix_bits_.value(x...); - KeeloqProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 91dfbadb0c..41ca3a2b04 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -23,17 +23,4 @@ class LGProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(LG) -template class LGAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - LGData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - LGProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.cpp b/esphome/components/remote_base/magiquest_protocol.cpp index 5b47f7e438..71a6a5ace1 100644 --- a/esphome/components/remote_base/magiquest_protocol.cpp +++ b/esphome/components/remote_base/magiquest_protocol.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { @@ -10,12 +10,25 @@ namespace esphome::remote_base { ESPHOME_LOG_TAG(TAG, "remote.magiquest"); static constexpr uint32_t MAGIQUEST_UNIT = 288; // us +// Half a unit is the widest window that still tells a one unit mark or space from a two unit one. +static constexpr uint32_t MAGIQUEST_TOLERANCE = MAGIQUEST_UNIT / 2; static constexpr uint32_t MAGIQUEST_ONE_MARK = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ONE_SPACE = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_MARK = MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_SPACE = 3 * MAGIQUEST_UNIT; +static bool checksum_is_valid(uint32_t wand_id, uint32_t magnitude_and_checksum) { + // The frame's six payload bytes, which start at the wand id's top bit, must sum to zero. + const uint32_t id_bytes = wand_id << 1; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_and_checksum >> shift); + } + return sum == 0; +} + void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &data) { + // Still the old frame layout, which the decoder no longer reads; changing it would alter what existing configs send. dst->reserve(101); // 2 start bits, 48 data bits, 1 stop bit dst->set_carrier_frequency(38000); @@ -41,41 +54,48 @@ void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &dat dst->mark(MAGIQUEST_UNIT); } optional MagiQuestProtocol::decode(RemoteReceiveData src) { - MagiQuestData data{ - .magnitude = 0, - .wand_id = 0, - }; - // Two start bits - if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE) || - !src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - return {}; + src.set_tolerance(MAGIQUEST_TOLERANCE, TOLERANCE_MODE_TIME); + + MagiQuestData data{}; + + // 8 bit header, always zero + for (uint8_t i = 0; i < 8; i++) { + if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { + return {}; + } } - for (uint32_t mask = 1 << 31; mask; mask >>= 1) { + // 31 bit wand id + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { data.wand_id |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.wand_id &= ~mask; - } else { + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } - for (uint16_t mask = 1 << 15; mask; mask >>= 1) { + // 9 bit magnitude, then 8 bit checksum + uint32_t magnitude_and_checksum = 0; + for (uint32_t mask = 1 << 16; mask > 1; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { - data.magnitude |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.magnitude &= ~mask; - } else { + magnitude_and_checksum |= mask; + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } + // The last bit has no trailing space. A wrong or missing mark here is caught by the checksum. + if (src.expect_mark(MAGIQUEST_ONE_MARK)) { + magnitude_and_checksum |= 1; + } - src.expect_mark(MAGIQUEST_UNIT); + if (!checksum_is_valid(data.wand_id, magnitude_and_checksum)) { + return {}; + } + data.magnitude = magnitude_and_checksum >> 8; return data; } void MagiQuestProtocol::dump(const MagiQuestData &data) { - ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=0x%04X", data.wand_id, data.magnitude); + ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=%u", data.wand_id, data.magnitude); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index f0d2410fe2..4e88f06cd4 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -5,23 +5,24 @@ #include /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { +// Older decoders read the wand id 5 bits too early, so ids in older configs lack its 5 low bits. +static constexpr uint8_t MAGIQUEST_LEGACY_WAND_ID_SHIFT = 5; + struct MagiQuestData { uint16_t magnitude; uint32_t wand_id; + // Not symmetric: `this` is the decoded frame and `rhs` the configured match. A configured wand_id of 0 matches + // any wand. Magnitude is not compared; it appears to encode the cast type rather than a strength. The legacy + // match applies to every configured id, so an id below 2^26 also matches the 32 wands whose full id shifts to it. bool operator==(const MagiQuestData &rhs) const { - // Treat 0xffff as a special, wildcard magnitude - // In testing, the wand never produces this value, and this allows us to match - // on just the wand_id if wanted. - if (rhs.wand_id != this->wand_id) { - return false; - } - return (this->wand_id == 0xffff || rhs.wand_id == 0xffff || this->wand_id == rhs.wand_id); + return rhs.wand_id == 0 || rhs.wand_id == this->wand_id || + rhs.wand_id == (this->wand_id >> MAGIQUEST_LEGACY_WAND_ID_SHIFT); } }; @@ -34,17 +35,4 @@ class MagiQuestProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(MagiQuest) -template class MagiQuestAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, magnitude) - TEMPLATABLE_VALUE(uint32_t, wand_id) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MagiQuestData data{}; - data.magnitude = this->magnitude_.value(x...); - data.wand_id = this->wand_id_.value(x...); - MagiQuestProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 19521e8f6d..572179605a 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -78,16 +78,4 @@ class MideaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Midea) -template class MideaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MideaData data = this->code_.is_static() ? MideaData(this->code_.data(), this->code_.size()) - : MideaData(this->code_.value(x...)); - data.finalize(); - MideaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index 23c6bd21e8..099388f8ec 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -1,7 +1,10 @@ #include "mirage_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::remote_base { ESPHOME_LOG_TAG(TAG, "remote.mirage"); @@ -17,8 +20,12 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { + // data may be a PROGMEM table, so log a RAM copy + uint8_t log_buf[MIRAGE_MAX_DATA_BYTES]; + const size_t log_len = std::min(len, MIRAGE_MAX_DATA_BYTES); + progmem_memcpy(log_buf, data, log_len); char hex_buf[format_hex_pretty_size(MIRAGE_MAX_DATA_BYTES)]; - ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data, len)); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, log_buf, log_len)); dst->set_carrier_frequency(38000); dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); @@ -26,7 +33,7 @@ void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t dst->mark(BIT_MARK_US); uint8_t checksum = 0; for (size_t i = 0; i < len; i++) { - const uint8_t item = data[i]; + const uint8_t item = progmem_read_byte(data + i); this->encode_byte_(dst, item); checksum += (item >> 4) + (item & 0xF); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index 923d412e84..ae6ed0a8e9 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -16,7 +16,11 @@ class MirageProtocol : public RemoteProtocol { void encode(RemoteTransmitData *dst, const MirageData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -26,13 +30,4 @@ class MirageProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Mirage) -template class MirageAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - this->code_.visit([dst](const uint8_t *data, size_t len) { MirageProtocol().encode(dst, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nec_protocol.cpp b/esphome/components/remote_base/nec_protocol.cpp index 3edb50141c..c4ba8c5ebc 100644 --- a/esphome/components/remote_base/nec_protocol.cpp +++ b/esphome/components/remote_base/nec_protocol.cpp @@ -1,4 +1,5 @@ #include "nec_protocol.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::remote_base { @@ -10,35 +11,42 @@ static constexpr uint32_t HEADER_LOW_US = 4500; static constexpr uint32_t BIT_HIGH_US = 560; static constexpr uint32_t BIT_ONE_LOW_US = 1690; static constexpr uint32_t BIT_ZERO_LOW_US = 560; +static constexpr uint32_t REPEAT_HEADER_LOW_US = HEADER_LOW_US / 2; +// A held button repeats every 108 ms: the first gap depends on the frame's own length, later ones follow a repeat frame +static constexpr uint32_t REPEAT_PERIOD_US = 108000; +static constexpr uint32_t REPEAT_FRAME_US = HEADER_HIGH_US + REPEAT_HEADER_LOW_US + BIT_HIGH_US; +static constexpr uint32_t REPEAT_GAP_US = REPEAT_PERIOD_US - REPEAT_FRAME_US; +// Transmitters without an RMT peripheral hold an interrupt lock for the whole send, about 108 ms per repeat +static constexpr uint16_t MAX_COMMAND_REPEATS = 10; void NECProtocol::encode(RemoteTransmitData *dst, const NECData &data) { ESP_LOGD(TAG, "Sending NEC: address=0x%04X, command=0x%04X command_repeats=%d", data.address, data.command, data.command_repeats); - dst->reserve(2 + 32 + 32 * data.command_repeats + 2); + // The schema bounds literal values; a lambda can produce anything, so clamp here too + const uint16_t repeats = clamp(data.command_repeats, 1, MAX_COMMAND_REPEATS); + // Frame: header pair, 32 bit pairs, stop mark. Each extra repeat: gap space, header pair, mark. + dst->reserve(2 + 64 + 1 + (repeats - 1) * 4); dst->set_carrier_frequency(38000); dst->item(HEADER_HIGH_US, HEADER_LOW_US); + uint32_t frame_us = HEADER_HIGH_US + HEADER_LOW_US + BIT_HIGH_US; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (uint16_t repeats = 0; repeats < data.command_repeats; repeats++) { + for (uint16_t value : {data.address, data.command}) { for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + const uint32_t low_us = (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US; + dst->item(BIT_HIGH_US, low_us); + frame_us += BIT_HIGH_US + low_us; } } dst->mark(BIT_HIGH_US); + + for (uint16_t repeat = 1; repeat < repeats; repeat++) { + dst->space(repeat == 1 ? REPEAT_PERIOD_US - frame_us : REPEAT_GAP_US); + dst->item(HEADER_HIGH_US, REPEAT_HEADER_LOW_US); + dst->mark(BIT_HIGH_US); + } } optional NECProtocol::decode(RemoteReceiveData src) { NECData data{ @@ -69,26 +77,7 @@ optional NECProtocol::decode(RemoteReceiveData src) { } } - while (src.peek_item(BIT_HIGH_US, BIT_ONE_LOW_US) || src.peek_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - uint16_t command = 0; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - command &= ~mask; - } else { - return {}; - } - } - - // Make sure the extra/repeated data matches original command - if (command != data.command) { - return {}; - } - - data.command_repeats += 1; - } - + // Repeat frames arrive as separate captures (receiver idle is far below the repeat gap) and are not decoded src.expect_mark(BIT_HIGH_US); return data; } diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 1337f7a8b3..849952c40f 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -21,19 +21,4 @@ class NECProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(NEC) -template class NECAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint16_t, command) - TEMPLATABLE_VALUE(uint16_t, command_repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - NECData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - data.command_repeats = this->command_repeats_.value(x...); - NECProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebf85387b0..b4d2ec4030 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -31,22 +31,4 @@ class NexaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Nexa) -template class NexaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, device) - TEMPLATABLE_VALUE(uint8_t, group) - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, level) - void encode(RemoteTransmitData *dst, Ts... x) override { - NexaData data{}; - data.device = this->device_.value(x...); - data.group = this->group_.value(x...); - data.state = this->state_.value(x...); - data.channel = this->channel_.value(x...); - data.level = this->level_.value(x...); - NexaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexus_protocol.cpp b/esphome/components/remote_base/nexus_protocol.cpp new file mode 100644 index 0000000000..6d7ddbb7eb --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.cpp @@ -0,0 +1,83 @@ +#include "nexus_protocol.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.nexus"); + +/** + * Example Nexus (Digoo) temperature & humidity sensor packet (36 data bits, each a mark and a space): + * 10000101 | 1 | 0 | 01 | 000010001010 | 1111 | 01000101 + * address (sensor ID) | battery | force update | channel | temperature | - | humidity + * + * The channel is sent 0-based and shown 1-based, temperature is 12 bit signed scaled by 10, + * `-`s are constant bits. + * + * Ref: https://manual.pilight.org/protocols/433.92/weather/nexus.html + */ + +static constexpr uint8_t NBITS = 36; + +static constexpr uint32_t SYNC_HIGH_US = 500; +static constexpr uint32_t SYNC_LOW_US = 3900; +static constexpr uint32_t BIT_HIGH_US = 500; +static constexpr uint32_t BIT_ZERO_LOW_US = 980; +static constexpr uint32_t BIT_ONE_LOW_US = 1950; + +void NexusProtocol::encode(RemoteTransmitData *dst, const NexusData &data) { + // A templated value may be out of range or NaN (an unavailable sensor); send 0 for NaN + const float temperature = std::isnan(data.temperature) ? 0.0f : std::clamp(data.temperature, -204.8f, 204.7f); + const auto t = static_cast(lroundf(temperature * 10.0f)); + const uint64_t packet = uint64_t(data.address) << 28 | uint64_t(data.battery_level) << 27 | + uint64_t(data.force_update) << 26 | uint64_t((data.channel - 1) & 0b11) << 24 | + uint64_t(t & 0x0FFF) << 12 | 0b1111 << 8 | data.humidity; + + dst->reserve(NBITS * 2 + 2); + for (int8_t i = NBITS - 1; i >= 0; i--) + dst->item(BIT_HIGH_US, (packet >> i) & 1 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + // The sync follows the bits, as the sensors send it: its mark ends the last space, its gap separates repeats + dst->item(SYNC_HIGH_US, SYNC_LOW_US); +} + +optional NexusProtocol::decode(RemoteReceiveData src) { + // A buffer may start with the previous frame's sync when the receiver did not split on it + src.expect_item(SYNC_HIGH_US, SYNC_LOW_US); + if (src.size() < NBITS * 2) + return {}; + + uint64_t packet = 0; + for (uint8_t i = 0; i < NBITS; i++) { + packet <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + packet |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + if (((packet >> 8) & 0xF) != 0xF) + return {}; + ESP_LOGV(TAG, "Nexus packet: 0x%09" PRIX64, packet); + + return NexusData{ + // The 12 bit field sits at bits 12-23; shift it to the top of an int16_t and back to sign extend. + .temperature = (static_cast((packet >> 8) & 0xFFF0) >> 4) / 10.0f, + .address = static_cast(packet >> 28), + .battery_level = static_cast((packet >> 27) & 1), + .force_update = static_cast((packet >> 26) & 1), + .channel = static_cast(((packet >> 24) & 0b11) + 1), + .humidity = static_cast(packet), + }; +} + +void NexusProtocol::dump(const NexusData &data) { + ESP_LOGI(TAG, + "Received Nexus: address=0x%02X, channel=%u, temperature=%.1f, humidity=%u, battery_level=%d, " + "force_update=%d", + data.address, data.channel, data.temperature, data.humidity, data.battery_level, data.force_update); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexus_protocol.h b/esphome/components/remote_base/nexus_protocol.h new file mode 100644 index 0000000000..de3f55777d --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.h @@ -0,0 +1,30 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct NexusData { + float temperature; // °C, sent as 12 bit signed tenths + uint8_t address; + bool battery_level; + bool force_update; + uint8_t channel; // sent 0-based, shown 1-based + uint8_t humidity; + + bool operator==(const NexusData &rhs) const { + // The binary sensor matches the transmitter, not the reading. + return channel == rhs.channel && address == rhs.address; + } +}; + +class NexusProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const NexusData &data); + optional decode(RemoteReceiveData src); + void dump(const NexusData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Nexus) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.h b/esphome/components/remote_base/onkyori_protocol.h index c6f0f1dd97..55efb1e9a3 100644 --- a/esphome/components/remote_base/onkyori_protocol.h +++ b/esphome/components/remote_base/onkyori_protocol.h @@ -19,15 +19,4 @@ class OnkyoRIProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(OnkyoRI) -template class OnkyoRIAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - OnkyoRIData data{}; - data.data = this->data_.value(x...); - OnkyoRIProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.cpp b/esphome/components/remote_base/panasonic_protocol.cpp index c2fc35d3ec..bedc9bca91 100644 --- a/esphome/components/remote_base/panasonic_protocol.cpp +++ b/esphome/components/remote_base/panasonic_protocol.cpp @@ -11,62 +11,72 @@ static constexpr uint32_t BIT_HIGH_US = 502; static constexpr uint32_t BIT_ZERO_LOW_US = 400; static constexpr uint32_t BIT_ONE_LOW_US = 1244; +static constexpr uint8_t NBITS_ADDRESS = 16; +static constexpr uint8_t NBITS_ADDRESS_2 = 8; +static constexpr uint8_t NBITS_COMMAND = 32; +static constexpr uint8_t NBITS_SHORT = NBITS_ADDRESS + NBITS_COMMAND; +static constexpr uint8_t NBITS_LONG = NBITS_SHORT + NBITS_ADDRESS_2; + +static void encode_bits(RemoteTransmitData *dst, uint32_t value, uint8_t nbits) { + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } +} + +static bool decode_bits(RemoteReceiveData &src, uint32_t &value, uint8_t nbits) { + value = 0; + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + value |= mask; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return false; + } + } + return true; +} + void PanasonicProtocol::encode(RemoteTransmitData *dst, const PanasonicData &data) { - dst->reserve(100); + if (data.nbits != NBITS_SHORT && data.nbits != NBITS_LONG) { + ESP_LOGW(TAG, "Unsupported nbits %u, expected 48 or 56", data.nbits); + return; + } + // Header, one item per bit, trailing mark + dst->reserve(3 + 2 * data.nbits); + dst->set_carrier_frequency(data.carrier_frequency); dst->item(HEADER_HIGH_US, HEADER_LOW_US); - dst->set_carrier_frequency(35000); - - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + encode_bits(dst, data.address, NBITS_ADDRESS); + if (data.nbits == NBITS_LONG) { + encode_bits(dst, data.address_2, NBITS_ADDRESS_2); } + encode_bits(dst, data.command, NBITS_COMMAND); dst->mark(BIT_HIGH_US); } + optional PanasonicProtocol::decode(RemoteReceiveData src) { - PanasonicData out{ - .address = 0, - .command = 0, - }; if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) return {}; - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.address |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.address &= ~mask; - } else { - return {}; - } - } + uint32_t address; + uint32_t word; + if (!decode_bits(src, address, NBITS_ADDRESS) || !decode_bits(src, word, NBITS_COMMAND)) + return {}; - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.command &= ~mask; - } else { - return {}; - } + PanasonicData out{}; + out.address = address; + uint32_t tail; + if (decode_bits(src, tail, NBITS_ADDRESS_2)) { + out.nbits = NBITS_LONG; + out.address_2 = word >> 24; + out.command = (word << 8) | tail; + } else { + out.command = word; } - return out; } + void PanasonicProtocol::dump(const PanasonicData &data) { - ESP_LOGI(TAG, "Received Panasonic: address=0x%04X, command=0x%08" PRIX32, data.address, data.command); + ESP_LOGI(TAG, "Received Panasonic: nbits=%u, address=0x%04X, address_2=0x%02X, command=0x%08" PRIX32, data.nbits, + data.address, data.address_2, data.command); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index 84df3c08b7..b560875982 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -8,10 +8,17 @@ namespace esphome::remote_base { struct PanasonicData { + // address and command stay first so positional initializers written for the 48-bit struct keep working. uint16_t address; uint32_t command; + uint8_t nbits{48}; // 48 or 56 + uint8_t address_2{0}; + // Transmit only: a receiver cannot measure the carrier, so it is not compared. + uint32_t carrier_frequency{35000}; - bool operator==(const PanasonicData &rhs) const { return address == rhs.address && command == rhs.command; } + bool operator==(const PanasonicData &rhs) const { + return address == rhs.address && address_2 == rhs.address_2 && command == rhs.command && nbits == rhs.nbits; + } }; class PanasonicProtocol : public RemoteProtocol { @@ -23,17 +30,4 @@ class PanasonicProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Panasonic) -template class PanasonicAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PanasonicData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - PanasonicProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pd_pioneer_protocol.cpp b/esphome/components/remote_base/pd_pioneer_protocol.cpp new file mode 100644 index 0000000000..72568be649 --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.cpp @@ -0,0 +1,57 @@ +#include "pd_pioneer_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pd_pioneer"); + +// Timings from captured Pronto codes (006D timebase, 26 us per word) +static constexpr uint32_t HEADER_MARK_US = 3120; +static constexpr uint32_t HEADER_SPACE_US = 1560; +static constexpr uint32_t BIT_MARK_US = 520; +static constexpr uint32_t BIT_ONE_SPACE_US = 1066; +static constexpr uint32_t BIT_ZERO_SPACE_US = 312; +static constexpr uint32_t FOOTER_MARK_US = 520; +static constexpr uint32_t FOOTER_SPACE_US = 10010; + +void PDPioneerProtocol::encode(RemoteTransmitData *dst, const PDPioneerData &src) { + dst->set_carrier_frequency(38000); + dst->reserve(2 + PDPioneerData::FRAME_SIZE * 8 * 2 + 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) + dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->item(FOOTER_MARK_US, FOOTER_SPACE_US); +} + +optional PDPioneerProtocol::decode(RemoteReceiveData src) { + PDPioneerData out; + if (!src.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) + return {}; + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) { + if (src.expect_item(BIT_MARK_US, BIT_ONE_SPACE_US)) { + out[idx] |= mask; + } else if (!src.expect_item(BIT_MARK_US, BIT_ZERO_SPACE_US)) { + return {}; + } + } + } + // The receiver's idle timeout usually ends the capture inside the footer space, so only the mark is required + if (!src.expect_mark(FOOTER_MARK_US)) + return {}; + if (!out.is_valid()) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGV(TAG, "Checksum mismatch: %s", out.to_str(buf)); + return {}; + } + return out; +} + +void PDPioneerProtocol::dump(const PDPioneerData &data) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGI(TAG, "Received PD-Pioneer: %s", data.to_str(buf)); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pd_pioneer_protocol.h b/esphome/components/remote_base/pd_pioneer_protocol.h new file mode 100644 index 0000000000..b324218f14 --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PDPioneerData { + public: + static constexpr uint8_t FRAME_SIZE = 14; + static constexpr uint8_t OFFSET_CS = 13; + + PDPioneerData() = default; + PDPioneerData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + + // Position 3 tells the two bursts of a command apart; the odd burst's checksum carries an extra 15 + bool is_odd_burst() const { return this->data_[3] == 0x02; } + bool is_even_burst() const { return this->data_[3] == 0x01; } + + bool is_valid() const { + return (this->is_odd_burst() || this->is_even_burst()) && this->data_[OFFSET_CS] == this->checksum_(); + } + + void finalize() { this->data_[OFFSET_CS] = this->checksum_(); } + + static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(FRAME_SIZE); + const char *to_str(char *buffer) const { + format_hex_pretty_to(buffer, TO_STR_BUFFER_SIZE, this->data_.data(), this->data_.size(), '.'); + return buffer; + } + + bool operator==(const PDPioneerData &rhs) const { return this->data_ == rhs.data_; } + + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + uint8_t checksum_() const { + uint8_t cs = this->is_odd_burst() ? 15 : 0; + for (uint8_t idx = 0; idx < OFFSET_CS; idx++) + cs += this->data_[idx]; + return cs; + } + + std::array data_{}; +}; + +class PDPioneerProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PDPioneerData &src); + optional decode(RemoteReceiveData src); + void dump(const PDPioneerData &data); +}; + +DECLARE_REMOTE_PROTOCOL(PDPioneer) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index d02bd3451f..b3d71d7ac9 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -20,17 +20,4 @@ class PioneerProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pioneer) -template class PioneerAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, rc_code_1) - TEMPLATABLE_VALUE(uint16_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PioneerData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - PioneerProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.cpp b/esphome/components/remote_base/pioneer_wyt_protocol.cpp new file mode 100644 index 0000000000..0c7d6c7ec9 --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.cpp @@ -0,0 +1,30 @@ +#include "pioneer_wyt_protocol.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pioneer_wyt"); + +// The timings and frame header are shared with tcl112 +static constexpr uint32_t HEADER_MARK_US = 3100; +static constexpr uint32_t HEADER_SPACE_US = 1650; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t BIT_ONE_SPACE_US = 1100; +static constexpr uint32_t BIT_ZERO_SPACE_US = 350; + +void PioneerWytProtocol::encode(RemoteTransmitData *dst, const PioneerWytData &data) { + char hex_buf[format_hex_pretty_size(PioneerWytData::SIZE)]; + ESP_LOGD(TAG, "Transmit PioneerWyt: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + dst->set_carrier_frequency(38000); + // header mark and space, a mark and space per bit, then the trailing mark + dst->reserve(dst->get_data().size() + 3 + PioneerWytData::SIZE * 8 * 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t i = 0; i < data.size(); i++) { + for (uint8_t b = 0; b < 8; b++) + dst->item(BIT_MARK_US, (data[i] & (1 << b)) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->mark(BIT_MARK_US); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.h b/esphome/components/remote_base/pioneer_wyt_protocol.h new file mode 100644 index 0000000000..9946d4230b --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PioneerWytData { + public: + static constexpr uint8_t SIZE = 14; + enum PioneerWytDataType : uint8_t { + PIONEER_WYT_TYPE_GENERAL = 0x01, + PIONEER_WYT_TYPE_FAN = 0x02, + }; + + // Make default + PioneerWytData() {} + // Make from initializer_list + PioneerWytData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + // Make from vector + PioneerWytData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PioneerWytData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + uint8_t size() const { return this->data_.size(); } + // FAN frames add 0x0F to the byte sum + void finalize() { + this->data_[OFFSET_CS] = + sum_bytes(this->data_.data(), OFFSET_CS, this->type() == PIONEER_WYT_TYPE_FAN ? 0x0F : 0x00); + } + PioneerWytDataType type() const { return static_cast(this->data_[3]); } + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + static constexpr uint8_t OFFSET_CS = SIZE - 1; + std::array data_{}; +}; + +class PioneerWytProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PioneerWytData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index bfd04c5cd9..4119b87f28 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -37,17 +37,4 @@ class ProntoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pronto) -template class ProntoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(std::string, data) - TEMPLATABLE_VALUE(int, delta) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ProntoData data{}; - data.data = this->data_.value(x...); - data.delta = this->delta_.value(x...); - ProntoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index f6f0f33c6e..d720fb11cc 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -21,17 +21,4 @@ class RC5Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC5) -template class RC5Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RC5Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC5Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5x_protocol.cpp b/esphome/components/remote_base/rc5x_protocol.cpp new file mode 100644 index 0000000000..501766f0aa --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.cpp @@ -0,0 +1,124 @@ +#include "rc5x_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.rc5x"); + +static constexpr uint32_t HALF_BIT_US = 889; // a Manchester bit is two half-bits +// Start, field and toggle bits plus the address, then a pause of 4 half-bits, then command and extension +static constexpr uint8_t RC5_TOGGLE_BIT = 1; +static constexpr uint8_t RC5_ADDRESS_BITS = 5; +static constexpr uint8_t FIRST_PART_NBITS = 8; +static constexpr uint8_t RC5_COMMAND_BITS = 6; +static constexpr uint8_t RC5_EXT_BITS = 6; +static constexpr uint8_t NBITS = 20; +static constexpr uint8_t PAUSE_HALFBITS = 4; +// Half-bits in a frame, counting the pause; the pause starts after the first part's half-bits +static constexpr uint8_t NFRAMEHALFBITS = NBITS * 2 + PAUSE_HALFBITS; +static constexpr uint8_t PAUSE_START = FIRST_PART_NBITS * 2; + +void RC5XProtocol::encode(RemoteTransmitData *dst, const RC5XData &data) { + static bool toggle = false; + dst->set_carrier_frequency(36000); + dst->reserve(NFRAMEHALFBITS); + // Start bit, then the field bit, which is clear for commands 64 to 127 + const uint8_t command = data.command & 0x7F; // bit 6 travels in the field bit, higher bits have no place + const uint32_t start_field = command >= 64 ? 0b10 : 0b11; + const uint32_t out_data = start_field << (RC5_TOGGLE_BIT + RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(toggle) << (RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(data.address & 0x1F) << (RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(command & 0x3F) << RC5_EXT_BITS | (data.extension & 0x3F); + + for (uint8_t bit = NBITS; bit-- > 0;) { + if (bit == NBITS - 1 - FIRST_PART_NBITS) { + for (uint8_t i = 0; i < PAUSE_HALFBITS; i++) + dst->space(HALF_BIT_US); + } + if (out_data & (1UL << bit)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + toggle = !toggle; +} + +optional RC5XProtocol::decode(RemoteReceiveData src) { + // Expand the runs into half-bit levels (true = mark), as the RC5 decoder does. Each run is one + // half-bit (HALF_BIT_US) or two, except the pause, which is PAUSE_HALFBITS half-bits at the idle + // level plus up to one merged half on each side (the end of bit 12 and the start of bit 11). + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped: S1 is 1, so its + // first half sits at the idle level and merges into the pre-frame idle. + bool halfbits[NFRAMEHALFBITS + 2]; + uint8_t n = 1; + bool pause_seen = false; + for (uint32_t i = 0; n <= NFRAMEHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(HALF_BIT_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(HALF_BIT_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * HALF_BIT_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * HALF_BIT_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; + } else { + // The pause must start right after bit 12, or one half earlier when bit 12 ends idle + if (pause_seen || (n != PAUSE_START && n != PAUSE_START - 1)) + break; + const int32_t value = src.peek(i); + const bool level = value > 0; + const uint32_t halves = (static_cast(value > 0 ? value : -value) + HALF_BIT_US / 2) / HALF_BIT_US; + if (level == halfbits[1] || halves < PAUSE_HALFBITS || halves > PAUSE_HALFBITS + 2) + break; + for (uint32_t h = 0; h < halves; h++) + halfbits[n++] = level; + // It may also run one half into bit 11, but must cover the whole pause + if (n != PAUSE_START + PAUSE_HALFBITS && n != PAUSE_START + PAUSE_HALFBITS + 1) + return {}; + pause_seen = true; + } + } + + // A full frame, or one half short when the last bit ends idle and its trailing half is dropped. + // A dropped edge half is the inverse of its partner, since a Manchester bit always changes mid-bit. + if (!pause_seen || (n != NFRAMEHALFBITS && n != NFRAMEHALFBITS - 1)) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NFRAMEHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; + } + + const bool carrier = halfbits[1]; + uint32_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const uint8_t h = 2 * i + (i < FIRST_PART_NBITS ? 0 : PAUSE_HALFBITS); + const bool first = halfbits[h]; + const bool second = halfbits[h + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + // [18 field] [17 toggle] [16-12 address] [11-6 command] [5-0 extension] + const bool field_bit = bits & (1UL << (NBITS - 2)); // S2: the inverted 7th command bit + return RC5XData{ + .address = static_cast((bits >> (RC5_COMMAND_BITS + RC5_EXT_BITS)) & 0x1F), + .command = static_cast(((bits >> RC5_EXT_BITS) & 0x3F) | (field_bit ? 0 : 0x40)), + .extension = static_cast(bits & 0x3F), + }; +} + +void RC5XProtocol::dump(const RC5XData &data) { + ESP_LOGI(TAG, "Received RC5X (Marantz): address=0x%02X, command=0x%02X, extension=0x%02X", data.address, data.command, + data.extension); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5x_protocol.h b/esphome/components/remote_base/rc5x_protocol.h new file mode 100644 index 0000000000..1d1b2c003d --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.h @@ -0,0 +1,31 @@ +#pragma once + +#include "remote_base.h" + +/* + * Marantz RC5 extended protocol (RC5x) + * See https://arduino-irremote.github.io/Arduino-IRremote/ir__RC5__RC6_8hpp_source.html + */ + +namespace esphome::remote_base { + +struct RC5XData { + uint8_t address; + uint8_t command; + uint8_t extension; + + bool operator==(const RC5XData &rhs) const { + return address == rhs.address && command == rhs.command && extension == rhs.extension; + } +}; + +class RC5XProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const RC5XData &data); + optional decode(RemoteReceiveData src); + void dump(const RC5XData &data); +}; + +DECLARE_REMOTE_PROTOCOL(RC5X) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index c4a2e8529b..cdeb8dfec7 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -22,23 +22,4 @@ class RC6Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC6) -template class RC6Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - RC6Data data{}; - data.mode = 0; - data.toggle = this->toggle_; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC6Protocol().encode(dst, data); - this->toggle_ = !this->toggle_; - } - - protected: - uint8_t toggle_{0}; -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 7205fb6221..3c5291ffb2 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,6 +182,20 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ +RemoteTransmitterBase::TransmitCall RemoteTransmitterBase::transmit() { + this->temp_.reset(); + return TransmitCall(this, this->take_seq_()); +} + +void RemoteTransmitterBase::transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, + uint32_t send_wait) { + auto call = this->transmit(); + encode(call.get_data(), data); + call.set_send_times(send_times); + call.set_send_wait(send_wait); + call.perform(); +} + void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 36d2a44c50..783343c5a2 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -187,20 +187,47 @@ class RemoteTransmitterBase : public RemoteComponentBase { uint16_t seq_; }; - TransmitCall transmit() { - this->temp_.reset(); - return TransmitCall(this, this->take_seq_()); - } + TransmitCall transmit(); template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { - auto call = this->transmit(); - Protocol().encode(call.get_data(), data); - call.set_send_times(send_times); - call.set_send_wait(send_wait); - call.perform(); + this->transmit_encoded_(&encode_thunk, &data, send_times, send_wait); + } + /// Protocol data filled field by field, then sent by perform(); the call a generated transmit action builds. + template class ProtocolCall { + public: + explicit ProtocolCall(RemoteTransmitterBase *parent) : parent_(parent) {} + void set_send_times(uint32_t send_times) { this->send_times_ = send_times; } + void set_send_wait(uint32_t send_wait) { this->send_wait_ = send_wait; } + void perform() { this->parent_->template transmit(this->data, this->send_times_, this->send_wait_); } + + typename Protocol::ProtocolData data{}; + + protected: + RemoteTransmitterBase *parent_; + uint32_t send_times_{1}; + uint32_t send_wait_{0}; + }; + template ProtocolCall protocol_call() { + return ProtocolCall(this); + } + /// A transmit call whose encode() runs one of Protocol's encoders on its data, for a payload + /// the protocol encodes straight from a pointer and length; the call a generated action builds. + template class EncodeCall : public TransmitCall { + public: + explicit EncodeCall(TransmitCall call) : TransmitCall(call) {} + template void encode(const Args &...args) { Protocol().encode(this->get_data(), args...); } + }; + template EncodeCall encode_call() { + return EncodeCall(this->transmit()); } protected: + using EncodeFn = void (*)(RemoteTransmitData *dst, const void *data); + // One out-of-line send path, so each transmit

() call site only fills its data. + void transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, uint32_t send_wait); + template static void encode_thunk(RemoteTransmitData *dst, const void *data) { + Protocol().encode(dst, *static_cast(data)); + } void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; /// Platforms that report completion later wait out the previous frame here, before send_() diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 8db025f812..a8fab14580 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -19,15 +19,4 @@ class RoombaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Roomba) -template class RoombaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RoombaData data{}; - data.data = this->data_.value(x...); - RoombaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index df4e1af8d8..c70a98b112 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -23,17 +23,4 @@ class Samsung36Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung36) -template class Samsung36Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Samsung36Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - Samsung36Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index dfa22ff85c..47aaa08e3a 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -21,17 +21,4 @@ class SamsungProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung) -template class SamsungAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SamsungData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SamsungProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/somfy_rts_protocol.cpp b/esphome/components/remote_base/somfy_rts_protocol.cpp new file mode 100644 index 0000000000..21c1f1d457 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.cpp @@ -0,0 +1,69 @@ +#include "somfy_rts_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +// Timings from https://pushstack.wordpress.com/somfy-rts-protocol/ +static constexpr uint8_t FRAME_SIZE_IN_BYTES = 7; +static constexpr uint16_t HALF_BIT_US = 604; + +static constexpr uint16_t WAKEUP_HIGH_US = 9415; +static constexpr uint32_t WAKEUP_LOW_US = 89565; + +static constexpr uint16_t HW_SYNC_US = 2560; + +static constexpr uint16_t SW_SYNC_HIGH_US = 4550; +static constexpr uint16_t SW_SYNC_LOW_US = HALF_BIT_US; + +// Wakeup, two hardware syncs and a software sync (8), two entries per bit (112) +static constexpr uint32_t FRAME_ENTRIES = 8 + FRAME_SIZE_IN_BYTES * 8 * 2; + +void SomfyRtsProtocol::encode(RemoteTransmitData *dst, const SomfyRtsData &data) { + dst->reserve(FRAME_ENTRIES); + dst->set_carrier_frequency(0); + dst->item(WAKEUP_HIGH_US, WAKEUP_LOW_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(SW_SYNC_HIGH_US, SW_SYNC_LOW_US); + + uint8_t frame[FRAME_SIZE_IN_BYTES]; + // Some non standard remotes put the control nibble in the key byte and send 0xF in the control nibble + if (data.control_in_key) { + frame[0] = (data.key & 0xF0) | (data.control & 0x0F); + frame[1] = 0xF0; + } else { + frame[0] = data.key; + frame[1] = (data.control & 0x0F) << 4; + } + frame[2] = data.rolling_code >> 8; + frame[3] = data.rolling_code; + frame[4] = data.address >> 16; + frame[5] = data.address >> 8; + frame[6] = data.address; + + uint8_t crc = 0; + for (uint8_t data_byte : frame) { + crc ^= (data_byte >> 4) ^ (data_byte & 0xF); + } + frame[1] |= (crc & 0x0F); + + for (uint8_t i = 1; i < FRAME_SIZE_IN_BYTES; i++) { + frame[i] ^= frame[i - 1]; + } + + // Manchester, MSB first: a 1 is space then mark, a 0 is mark then space. Frames repeat with + // the 30 ms gap through the action's repeat option, not inside the frame. + for (uint8_t data_byte : frame) { + for (int8_t y = 7; y >= 0; y--) { + if (data_byte & (1 << y)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/somfy_rts_protocol.h b/esphome/components/remote_base/somfy_rts_protocol.h new file mode 100644 index 0000000000..df6ddd4831 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.h @@ -0,0 +1,21 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct SomfyRtsData { + uint32_t address; + uint16_t rolling_code; + uint8_t key; + uint8_t control; + bool control_in_key; +}; + +// Transmit only: there is no decoder yet, so no binary sensor, trigger or dumper is registered. +class SomfyRtsProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const SomfyRtsData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index f83b2908b6..9bf488099b 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -23,17 +23,4 @@ class SonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Sony) -template class SonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 40a5c2daec..56a4d7c6d3 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -24,19 +24,4 @@ class SymphonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Symphony) -template class SymphonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SymphonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - data.repeats = this->repeats_.value(x...); - SymphonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 35d5af314c..482b338cda 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -21,17 +21,4 @@ class ToshibaAcProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ToshibaAc) -template class ToshibaAcAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, rc_code_1) - TEMPLATABLE_VALUE(uint64_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ToshibaAcData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - ToshibaAcProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 8e965a5c73..c845087684 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -23,19 +23,4 @@ class TotoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Toto) -template class TotoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, rc_code_1) - TEMPLATABLE_VALUE(uint8_t, rc_code_2) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - TotoData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - data.command = this->command_.value(x...); - TotoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py new file mode 100644 index 0000000000..808f27a67a --- /dev/null +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import ble_client +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["ble_client"] +DOMAIN = "renogy_inverter_ble" +MULTI_CONF = True + +CONF_RENOGY_INVERTER_BLE_ID = "renogy_inverter_ble_id" + +renogy_inverter_ble_ns = cg.esphome_ns.namespace(DOMAIN) +RenogyInverterBle = renogy_inverter_ble_ns.class_( + "RenogyInverterBle", ble_client.BLEClientNode, cg.PollingComponent +) + +RENOGY_INVERTER_BLE_COMPONENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RENOGY_INVERTER_BLE_ID): cv.use_id(RenogyInverterBle), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RenogyInverterBle), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + + +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) diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp new file mode 100644 index 0000000000..4cb43b5efb --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp @@ -0,0 +1,248 @@ +#include "renogy_inverter_ble.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::renogy_inverter_ble { + +ESPHOME_LOG_TAG(TAG, "renogy_inverter_ble"); + +static constexpr uint16_t SERVICE_NOTIFY_UUID = 0xFFF0; +static constexpr uint16_t CHAR_NOTIFY_UUID = 0xFFF1; +static constexpr uint16_t SERVICE_WRITE_UUID = 0xFFD0; +static constexpr uint16_t CHAR_WRITE_UUID = 0xFFD1; +static constexpr uint16_t CHAR_INIT_UUID = 0xFFD4; + +static constexpr uint8_t MODBUS_DEVICE_ID = 0x20; +static constexpr uint8_t MODBUS_READ_HOLDING = 0x03; +static constexpr uint16_t REG_MAIN = 4000; +static constexpr uint16_t REG_MAIN_WORDS = 32; +static constexpr uint16_t REG_LOAD = 4408; +static constexpr uint16_t REG_LOAD_WORDS = 6; + +// Watchdog: returns the cycle to IDLE when the inverter stops answering +static constexpr uint32_t CYCLE_TIMEOUT_MS = 3000; +// Gap the inverter needs between two consecutive register reads +static constexpr uint32_t LOAD_READ_DELAY_MS = 300; + +static constexpr uint32_t CYCLE_TIMEOUT_ID = 0; +static constexpr uint32_t LOAD_READ_ID = 1; + +static void publish(sensor::Sensor *s, float value) { + if (s != nullptr) { + s->publish_state(value); + } +} + +void RenogyInverterBle::dump_config() { + ESP_LOGCONFIG(TAG, "Renogy Inverter BLE:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "AC input voltage", this->ac_input_voltage_sensor_); + LOG_SENSOR(" ", "AC output voltage", this->ac_output_voltage_sensor_); + LOG_SENSOR(" ", "AC output current", this->ac_output_current_sensor_); + LOG_SENSOR(" ", "AC output frequency", this->ac_output_frequency_sensor_); + LOG_SENSOR(" ", "Input frequency", this->input_frequency_sensor_); + LOG_SENSOR(" ", "Battery voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Load current", this->load_current_sensor_); + LOG_SENSOR(" ", "Load active power", this->load_active_power_sensor_); + LOG_SENSOR(" ", "Load apparent power", this->load_apparent_power_sensor_); +} + +void RenogyInverterBle::abort_cycle_() { + this->cancel_timeout(CYCLE_TIMEOUT_ID); + this->cancel_timeout(LOAD_READ_ID); + this->frame_len_ = 0; + this->state_ = State::IDLE; +} + +void RenogyInverterBle::read_register_(uint16_t start_register, uint16_t word_count) { + std::array req{MODBUS_DEVICE_ID, + MODBUS_READ_HOLDING, + static_cast(start_register >> 8), + static_cast(start_register), + static_cast(word_count >> 8), + static_cast(word_count)}; + const uint16_t crc = crc16(req.data(), 6); + req[6] = crc & 0xFF; // Modbus sends the CRC low byte first + req[7] = crc >> 8; + + this->frame_len_ = 0; + const esp_err_t status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->write_handle_, + req.size(), req.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "write_char (reg %u) failed, status=%d", start_register, status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::start_cycle_() { + this->set_timeout(CYCLE_TIMEOUT_ID, CYCLE_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "Read cycle timed out (state=%u); resetting", static_cast(this->state_)); + this->abort_cycle_(); + }); + if (this->init_handle_ == 0) { + // No init characteristic discovered; try the main read directly + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + return; + } + this->state_ = State::INIT; + const esp_err_t status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->init_handle_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "init read_char failed, status=%d", status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::update() { + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGD(TAG, "Not connected yet; skipping poll"); + return; + } + if (this->state_ != State::IDLE) { + ESP_LOGD(TAG, "Previous read cycle still in progress; skipping"); + return; + } + this->start_cycle_(); +} + +void RenogyInverterBle::on_frame_complete_(uint16_t len) { + // A Modbus frame with its CRC appended checks to zero + if (len < 5 || crc16(this->frame_.data(), len) != 0) { + ESP_LOGW(TAG, "CRC mismatch (state=%u, len=%u)", static_cast(this->state_), len); + this->abort_cycle_(); + return; + } + // Exception response: function code with the high bit set, third byte is the exception code + if ((this->frame_[1] & 0x80) != 0) { + ESP_LOGW(TAG, "Modbus exception 0x%02X (state=%u)", this->frame_[2], static_cast(this->state_)); + this->abort_cycle_(); + return; + } + // Only the reply to the outstanding read is accepted: same device, same function, the requested word count + const bool awaiting_reply = this->state_ == State::MAIN || this->state_ == State::LOAD; + const uint16_t expected_words = this->state_ == State::MAIN ? REG_MAIN_WORDS : REG_LOAD_WORDS; + if (!awaiting_reply || this->frame_[0] != MODBUS_DEVICE_ID || this->frame_[1] != MODBUS_READ_HOLDING || + this->frame_[2] != 2 * expected_words) { + ESP_LOGW(TAG, "Unexpected response %02X %02X %02X (state=%u)", this->frame_[0], this->frame_[1], this->frame_[2], + static_cast(this->state_)); + this->abort_cycle_(); + return; + } + auto reg16 = [this](uint8_t i) { return encode_uint16(this->frame_[3 + 2 * i], this->frame_[4 + 2 * i]); }; + + if (this->state_ == State::MAIN) { + publish(this->ac_input_voltage_sensor_, reg16(0) * 0.1f); + publish(this->ac_output_voltage_sensor_, reg16(2) * 0.1f); + publish(this->ac_output_current_sensor_, reg16(3) * 0.01f); + publish(this->ac_output_frequency_sensor_, reg16(4) * 0.01f); + publish(this->battery_voltage_sensor_, reg16(5) * 0.1f); + publish(this->temperature_sensor_, reg16(6) * 0.1f); + publish(this->input_frequency_sensor_, reg16(9) * 0.01f); + this->frame_len_ = 0; + this->set_timeout(LOAD_READ_ID, LOAD_READ_DELAY_MS, [this]() { + this->state_ = State::LOAD; + this->read_register_(REG_LOAD, REG_LOAD_WORDS); + }); + return; + } + publish(this->load_current_sensor_, reg16(0) * 0.01f); + publish(this->load_active_power_sensor_, static_cast(reg16(1))); + publish(this->load_apparent_power_sensor_, static_cast(reg16(2))); + this->abort_cycle_(); +} + +void RenogyInverterBle::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: { + this->abort_cycle_(); + this->node_state = espbt::ClientState::IDLE; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + auto *notify_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_NOTIFY_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_NOTIFY_UUID)); + auto *write_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_WRITE_UUID)); + auto *init_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_INIT_UUID)); + if (notify_chr == nullptr || write_chr == nullptr) { + ESP_LOGE(TAG, "Required characteristics not found; not a Renogy inverter?"); + break; + } + this->notify_handle_ = notify_chr->handle; + this->write_handle_ = write_chr->handle; + this->init_handle_ = (init_chr != nullptr) ? init_chr->handle : 0; + ESP_LOGI(TAG, "handles: notify=%u write=%u init=%u", this->notify_handle_, this->write_handle_, + this->init_handle_); + const esp_err_t status = this->parent_->register_for_notify(this->notify_handle_); + if (status != ESP_OK) { + ESP_LOGW(TAG, "register_for_notify failed, status=%d", status); + } + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + // Other nodes on the same client see this event too + if (param->reg_for_notify.handle != this->notify_handle_) { + break; + } + if (param->reg_for_notify.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "Notification registration failed, status=%d", param->reg_for_notify.status); + break; + } + this->node_state = espbt::ClientState::ESTABLISHED; + ESP_LOGI(TAG, "Connected; will poll the inverter every update interval"); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (this->state_ != State::INIT || param->read.handle != this->init_handle_) { + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "init read failed, status=%d", param->read.status); + this->abort_cycle_(); + break; + } + // Init handshake done; the inverter now answers Modbus reads + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + break; + } + const uint16_t value_len = param->notify.value_len; + if (this->frame_len_ + value_len > MAX_FRAME) { + ESP_LOGW(TAG, "Frame overflow; resetting"); + this->abort_cycle_(); + break; + } + memcpy(&this->frame_[this->frame_len_], param->notify.value, value_len); + this->frame_len_ += value_len; + if (this->frame_len_ < 3) { + break; + } + // An exception frame ([id][func|0x80][exc][crc][crc]) is 5 bytes; a normal response is + // [id][func][byte_count][...data][crc][crc] + const uint16_t expected_len = ((this->frame_[1] & 0x80) != 0) ? 5 : 3 + this->frame_[2] + 2; + if (this->frame_len_ >= expected_len) { + this->on_frame_complete_(expected_len); + } + break; + } + default: + break; + } +} + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h new file mode 100644 index 0000000000..41bcdddf4b --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::renogy_inverter_ble { + +namespace espbt = esphome::esp32_ble_tracker; + +// Largest Modbus response: 3 header + 2 * 32 data + 2 CRC bytes +static constexpr size_t MAX_FRAME = 69; + +// Renogy inverter BLE GATT (RIV1230PU). Notify char 0xFFF1 (svc 0xFFF0), write char 0xFFD1 +// (svc 0xFFD0), init char 0xFFD4 (svc 0xFFD0). Modbus device id 0x20, function 0x03. +class RenogyInverterBle : public ble_client::BLEClientNode, public PollingComponent { + SUB_SENSOR(ac_input_voltage) + SUB_SENSOR(ac_output_voltage) + SUB_SENSOR(ac_output_current) + SUB_SENSOR(ac_output_frequency) + SUB_SENSOR(input_frequency) + SUB_SENSOR(battery_voltage) + SUB_SENSOR(temperature) + SUB_SENSOR(load_current) + SUB_SENSOR(load_active_power) + SUB_SENSOR(load_apparent_power) + + public: + void dump_config() override; + void update() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + + protected: + // Read cycle: IDLE -> (update) INIT read(0xFFD4) -> MAIN read(4000) -> LOAD read(4408) -> IDLE + enum class State : uint8_t { IDLE, INIT, MAIN, LOAD }; + + void start_cycle_(); + void read_register_(uint16_t start_register, uint16_t word_count); + void on_frame_complete_(uint16_t len); + void abort_cycle_(); + + State state_{State::IDLE}; + uint8_t frame_len_{0}; + uint16_t notify_handle_{0}; + uint16_t write_handle_{0}; + uint16_t init_handle_{0}; + // Reassembles the Modbus response, which arrives in MTU-sized notification chunks + std::array frame_; +}; + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/sensor.py b/esphome/components/renogy_inverter_ble/sensor.py new file mode 100644 index 0000000000..9b87cc6503 --- /dev/null +++ b/esphome/components/renogy_inverter_ble/sensor.py @@ -0,0 +1,99 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_VOLTAGE, + CONF_TEMPERATURE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_HERTZ, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) +from esphome.types import ConfigType + +from . import CONF_RENOGY_INVERTER_BLE_ID, RENOGY_INVERTER_BLE_COMPONENT_SCHEMA + +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["renogy_inverter_ble"] + +CONF_AC_INPUT_VOLTAGE = "ac_input_voltage" +CONF_AC_OUTPUT_VOLTAGE = "ac_output_voltage" +CONF_AC_OUTPUT_CURRENT = "ac_output_current" +CONF_AC_OUTPUT_FREQUENCY = "ac_output_frequency" +CONF_INPUT_FREQUENCY = "input_frequency" +CONF_LOAD_CURRENT = "load_current" +CONF_LOAD_ACTIVE_POWER = "load_active_power" +CONF_LOAD_APPARENT_POWER = "load_apparent_power" + +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = RENOGY_INVERTER_BLE_COMPONENT_SCHEMA.extend( + { + cv.Optional(CONF_AC_INPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_INPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_BATTERY_VOLTAGE): VOLTAGE_SCHEMA, + 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_LOAD_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_LOAD_ACTIVE_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_RENOGY_INVERTER_BLE_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_INPUT_VOLTAGE, hub.set_ac_input_voltage_sensor) + await sensors(CONF_AC_OUTPUT_VOLTAGE, hub.set_ac_output_voltage_sensor) + await sensors(CONF_AC_OUTPUT_CURRENT, hub.set_ac_output_current_sensor) + await sensors(CONF_AC_OUTPUT_FREQUENCY, hub.set_ac_output_frequency_sensor) + await sensors(CONF_INPUT_FREQUENCY, hub.set_input_frequency_sensor) + await sensors(CONF_BATTERY_VOLTAGE, hub.set_battery_voltage_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_LOAD_CURRENT, hub.set_load_current_sensor) + await sensors(CONF_LOAD_ACTIVE_POWER, hub.set_load_active_power_sensor) + await sensors(CONF_LOAD_APPARENT_POWER, hub.set_load_apparent_power_sensor) diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 76dd240f7a..d46858d397 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -195,6 +195,8 @@ def _parse_platform_version(value: Any) -> str: # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 1b2236ae8e..bfdff910da 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -8,9 +8,9 @@ DEPENDENCIES = ["rp2"] DOMAIN = "rp2_pio" -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -21,8 +21,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 7056ce35aa..55e25d76b7 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -29,4 +29,7 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { } } +uint8_t *ImageDecoder::image_buffer_() const { return this->image_->buffer_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->is_big_endian_; } + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 2a8b393888..8e0399ac35 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -127,6 +127,10 @@ class ImageDecoder { } protected: + /// The image's pixel buffer, for decoders that write their output straight into it + uint8_t *image_buffer_() const; + bool image_is_big_endian_() const; + double x_scale_ = 1.0; double y_scale_ = 1.0; RuntimeImage *image_; diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 74ced63a4e..d4783701c4 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -2,6 +2,7 @@ #ifdef USE_RUNTIME_IMAGE_JPEG #include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -15,6 +16,67 @@ ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg"); namespace esphome::runtime_image { +bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) { + if (this->x_boundaries_.capacity() < static_cast(width + 1) && !this->x_boundaries_.try_init(width + 1)) { + return false; + } + if (this->y_boundaries_.capacity() < static_cast(height + 1) && !this->y_boundaries_.try_init(height + 1)) { + return false; + } + + this->source_width_ = width; + this->source_height_ = height; + this->x_boundaries_.clear(); + this->y_boundaries_.clear(); + const uint32_t target_width = this->image_->get_buffer_width(); + const uint32_t target_height = this->image_->get_buffer_height(); + // Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source + // pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6; + // the generic draw() path rounds down and keeps the odd ones instead + for (uint32_t x = 0; x <= static_cast(width); x++) { + this->x_boundaries_.push_back(static_cast((x * target_width + width - 1) / width)); + } + for (uint32_t y = 0; y <= static_cast(height); y++) { + this->y_boundaries_.push_back(static_cast((y * target_height + height - 1) / height)); + } + return true; +} + +void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) { + if (pixels == nullptr) { + return; + } + uint8_t *const buffer = this->image_buffer_(); + const uint32_t buffer_width = this->image_->get_buffer_width(); + // JPEGDEC gives little-endian words; a big-endian image stores the high byte first + const uint8_t first = this->image_is_big_endian_() ? 1 : 0; + for (int source_y = 0; source_y < h; source_y++) { + const int absolute_y = y + source_y; + if (absolute_y < 0 || absolute_y >= this->source_height_) { + continue; + } + const uint16_t y1 = this->y_boundaries_[absolute_y]; + const uint16_t y2 = this->y_boundaries_[absolute_y + 1]; + for (int source_x = 0; source_x < w; source_x++) { + const int absolute_x = x + source_x; + if (absolute_x < 0 || absolute_x >= this->source_width_) { + continue; + } + const uint16_t x1 = this->x_boundaries_[absolute_x]; + const uint16_t x2 = this->x_boundaries_[absolute_x + 1]; + const uint8_t *pixel = pixels + (source_y * w + source_x) * 2; + for (uint16_t destination_y = y1; destination_y < y2; destination_y++) { + uint8_t *destination = buffer + (destination_y * buffer_width + x1) * 2; + for (uint16_t destination_x = x1; destination_x < x2; destination_x++) { + destination[0] = pixel[first]; + destination[1] = pixel[1 - first]; + destination += 2; + } + } + } + } +} + /** * @brief Callback method that will be called by the JPEGDEC engine when a chunk * of the image is decoded. @@ -22,7 +84,12 @@ namespace esphome::runtime_image { * @param jpeg The JPEGDRAW object, including the context data. */ static int draw_callback(JPEGDRAW *jpeg) { - ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser; + JpegDecoder *decoder = (JpegDecoder *) jpeg->pUser; + + if (decoder == nullptr) { + ESP_LOGE(TAG, "Decoder pointer is null!"); + return 0; + } // Some very big images take too long to decode, so feed the watchdog on each callback // to avoid crashing if the executing task has a watchdog enabled. @@ -33,6 +100,11 @@ static int draw_callback(JPEGDRAW *jpeg) { #ifdef USE_ESP_IDF } #endif + if (decoder->uses_direct_rgb565()) { + decoder->draw_rgb565(jpeg->x, jpeg->y, jpeg->iWidth, jpeg->iHeight, + reinterpret_cast(jpeg->pPixels)); + return 1; + } size_t position = 0; size_t height = static_cast(jpeg->iHeight); size_t width = static_cast(jpeg->iWidth); @@ -42,10 +114,6 @@ static int draw_callback(JPEGDRAW *jpeg) { auto ba = decode_value(jpeg->pPixels[position++]); Color color(rg[1], rg[0], ba[1], ba[0]); - if (!decoder) { - ESP_LOGE(TAG, "Decoder pointer is null!"); - return 0; - } decoder->draw(jpeg->x + x, jpeg->y + y, 1, 1, color); } } @@ -78,10 +146,20 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp()); this->jpeg_.setUserPointer(this); - this->jpeg_.setPixelType(RGB8888); + this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency(); + if (this->direct_rgb565_) { + // JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build + // returns little-endian words for it), so the byte order is applied in draw_rgb565 instead + this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN); + } else { + this->jpeg_.setPixelType(RGB8888); + } if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { return DECODE_ERROR_OUT_OF_MEMORY; } + if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { + return DECODE_ERROR_OUT_OF_MEMORY; + } if (!this->jpeg_.decode(0, 0, 0)) { auto error = this->jpeg_.getLastError(); ESP_LOGE(TAG, "Error while decoding: %d", error); diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index 67c9b77f4d..f72f69422d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -1,8 +1,8 @@ #pragma once - #include "image_decoder.h" #include "runtime_image.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_RUNTIME_IMAGE_JPEG #include @@ -23,8 +23,18 @@ class JpegDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; + bool uses_direct_rgb565() const { return this->direct_rgb565_; } + void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels); + protected: + bool prepare_rgb565_scaling_(int width, int height); + JPEGDEC jpeg_{}; + FixedVector x_boundaries_{}; + FixedVector y_boundaries_{}; + int source_width_{0}; + int source_height_{0}; + bool direct_rgb565_{false}; }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 55d5c0ae86..33bad52acf 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -234,6 +234,8 @@ class RuntimeImage : public image::Image { bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; + + friend class ImageDecoder; }; } // namespace esphome::runtime_image diff --git a/esphome/components/samsung/__init__.py b/esphome/components/samsung/__init__.py new file mode 100644 index 0000000000..fb22727b36 --- /dev/null +++ b/esphome/components/samsung/__init__.py @@ -0,0 +1 @@ +DOMAIN = "samsung" diff --git a/esphome/components/samsung/climate.py b/esphome/components/samsung/climate.py new file mode 100644 index 0000000000..ae59d17f96 --- /dev/null +++ b/esphome/components/samsung/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.types import ConfigType + +CODEOWNERS = ["@jorofi"] + +AUTO_LOAD = ["climate_ir"] + +samsung_ns = cg.esphome_ns.namespace("samsung") +SamsungClimate = samsung_ns.class_("SamsungClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SamsungClimate) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/samsung/samsung.cpp b/esphome/components/samsung/samsung.cpp new file mode 100644 index 0000000000..65f1cd10d6 --- /dev/null +++ b/esphome/components/samsung/samsung.cpp @@ -0,0 +1,235 @@ +#include "samsung.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::samsung { + +ESPHOME_LOG_TAG(TAG, "samsung.climate"); + +static constexpr uint32_t CARRIER_FREQUENCY_HZ = 38000; +static constexpr uint32_t HEADER_MARK_US = 3000; +static constexpr uint32_t HEADER_SPACE_US = 9000; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t ONE_SPACE_US = 1500; +static constexpr uint32_t ZERO_SPACE_US = 500; +static constexpr uint32_t SECTION_SPACE_US = 2000; + +static constexpr uint8_t EXTENDED_LENGTH = 3 * SAMSUNG_AC_SECTION_LENGTH; + +// A powered-on unit at 24 °C, cool mode, fan auto, swing off (from IRremoteESP8266); checksums are refilled on send +static const uint8_t RESET_STATE[SAMSUNG_AC_STATE_LENGTH] PROGMEM = {0x02, 0x92, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x01, 0xE2, 0xFE, 0x71, 0x80, 0x11, 0xF0}; +// Middle section of a power on/off frame: an all-zero timer block +static const uint8_t TIMER_SECTION[SAMSUNG_AC_SECTION_LENGTH] PROGMEM = {0x01, 0xD2, 0x0F, 0x00, 0x00, 0x00, 0x00}; + +// Where each setting lives in the 14 byte state +struct Field { + uint8_t byte; + uint8_t shift; + uint8_t mask; +}; +static constexpr Field POWER_1{6, 4, 0b11}; +static constexpr Field SWING{9, 4, 0b111}; +static constexpr Field TEMP{11, 4, 0b1111}; +static constexpr Field FAN{12, 1, 0b111}; +static constexpr Field MODE{12, 4, 0b111}; +static constexpr Field POWER_2{13, 4, 0b11}; +static constexpr uint8_t POWER_ON = 0b11; + +static uint8_t get_field(const uint8_t *state, Field field) { return (state[field.byte] >> field.shift) & field.mask; } +static void set_field(uint8_t *state, Field field, uint8_t value) { + state[field.byte] = (state[field.byte] & ~(field.mask << field.shift)) | ((value & field.mask) << field.shift); +} +static bool is_powered_on(const uint8_t *state) { + return get_field(state, POWER_1) == POWER_ON && get_field(state, POWER_2) == POWER_ON; +} + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +// The first entry of each table is the fallback in both directions +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, 0}, {climate::CLIMATE_MODE_COOL, 1}, {climate::CLIMATE_MODE_DRY, 2}, + {climate::CLIMATE_MODE_FAN_ONLY, 3}, {climate::CLIMATE_MODE_HEAT, 4}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0}, + {climate::CLIMATE_FAN_LOW, 2}, + {climate::CLIMATE_FAN_MEDIUM, 4}, + {climate::CLIMATE_FAN_HIGH, 5}, +}; +static constexpr Code SWINGS[] PROGMEM = { + {climate::CLIMATE_SWING_OFF, 0b111}, + {climate::CLIMATE_SWING_VERTICAL, 0b010}, + {climate::CLIMATE_SWING_HORIZONTAL, 0b011}, + {climate::CLIMATE_SWING_BOTH, 0b100}, +}; + +// Returns the entry whose value (or code) matches key, else the first entry. Entries are copied out of PROGMEM. +static Code find_entry(const Code *table, size_t count, uint8_t key, bool match_code) { + Code entry{}; + for (size_t i = 0; i < count; i++) { + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + progmem_memcpy(&entry, table, sizeof(entry)); + return entry; +} +template static uint8_t to_code(const Code (&table)[N], uint8_t value) { + return find_entry(table, N, value, false).code; +} +template static T from_code(const Code (&table)[N], uint8_t code) { + return static_cast(find_entry(table, N, code, true).value); +} + +// Inverted count of the one bits in a section, skipping the two nibbles that hold the checksum itself. +// See https://github.com/crankyoldgit/IRremoteESP8266/issues/1538#issuecomment-894645947 +static uint8_t section_checksum(const uint8_t *section) { + uint32_t ones = + __builtin_popcount(section[0]) + __builtin_popcount(section[1] & 0x0F) + __builtin_popcount(section[2] & 0xF0); + for (uint8_t i = 3; i < SAMSUNG_AC_SECTION_LENGTH; i++) { + ones += __builtin_popcount(section[i]); + } + return ~static_cast(ones); +} +static uint8_t stored_checksum(const uint8_t *section) { return (section[1] >> 4) | (section[2] << 4); } +static void store_checksum(uint8_t *section) { + const uint8_t sum = section_checksum(section); + section[1] = (section[1] & 0x0F) | (sum << 4); + section[2] = (section[2] & 0xF0) | (sum >> 4); +} + +SamsungClimate::SamsungClimate() + : climate_ir::ClimateIR( + SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}) { + progmem_memcpy(this->state_.data(), RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + // The unit's power state is unknown at boot, so the first "on" command sends a power frame + set_field(this->state_.data(), POWER_1, 0); + set_field(this->state_.data(), POWER_2, 0); +} + +void SamsungClimate::transmit_state() { + uint8_t *state = this->state_.data(); + const bool was_on = is_powered_on(state); + const bool on = this->mode != climate::CLIMATE_MODE_OFF; + + if (on) { + progmem_memcpy(state, RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + set_field(state, MODE, to_code(MODES, this->mode)); + } // else keep the last mode so the power off frame still carries a valid state + + const uint8_t temp = clamp(this->target_temperature, SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX); + set_field(state, TEMP, temp - SAMSUNG_AC_TEMP_MIN); + set_field(state, SWING, to_code(SWINGS, this->swing_mode)); + set_field(state, FAN, to_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO))); + set_field(state, POWER_1, on ? POWER_ON : 0); + set_field(state, POWER_2, on ? POWER_ON : 0); + + if (on != was_on) { + // A power change is sent as an extended frame with the timer block between the two state sections + uint8_t timer[SAMSUNG_AC_SECTION_LENGTH]; + progmem_memcpy(timer, TIMER_SECTION, SAMSUNG_AC_SECTION_LENGTH); + const uint8_t *sections[] = {state, timer, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 3); + } else { + const uint8_t *sections[] = {state, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 2); + } +} + +void SamsungClimate::send_sections_(const uint8_t *const *sections, uint8_t count) { + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + // Header(2) + (sections - 1) * separator(4) + bits(2 each) + trailing mark(1) + data->reserve(2 + (count - 1) * 4 + count * SAMSUNG_AC_SECTION_LENGTH * 8 * 2 + 1); + data->set_carrier_frequency(CARRIER_FREQUENCY_HZ); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + + for (uint8_t s = 0; s < count; s++) { + if (s != 0) { + data->item(BIT_MARK_US, SECTION_SPACE_US); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + } + uint8_t section[SAMSUNG_AC_SECTION_LENGTH]; + memcpy(section, sections[s], SAMSUNG_AC_SECTION_LENGTH); + store_checksum(section); + for (uint8_t byte : section) { + for (uint8_t bit = 0; bit < 8; bit++, byte >>= 1) { + data->item(BIT_MARK_US, (byte & 1) ? ONE_SPACE_US : ZERO_SPACE_US); + } + } + } + + data->mark(BIT_MARK_US); + transmit.perform(); +} + +bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) { + if (!data.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) { + return false; + } + + // Decode into a local buffer so a bad frame leaves the stored state untouched + uint8_t frame[EXTENDED_LENGTH] = {0}; + uint8_t length = EXTENDED_LENGTH; + for (uint8_t i = 0; i < EXTENDED_LENGTH; i++) { + if (i != 0 && i % SAMSUNG_AC_SECTION_LENGTH == 0) { + const bool have_separator = + data.expect_item(BIT_MARK_US, SECTION_SPACE_US) && data.expect_item(HEADER_MARK_US, HEADER_SPACE_US); + if (!have_separator) { + // A third section is optional; a missing separator after the second one ends the frame + if (i >= SAMSUNG_AC_STATE_LENGTH) { + length = i; + break; + } + ESP_LOGW(TAG, "Missing section separator before byte %u", i); + return false; + } + } + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(BIT_MARK_US, ONE_SPACE_US)) { + frame[i] |= 1 << bit; + } else if (!data.expect_item(BIT_MARK_US, ZERO_SPACE_US)) { + ESP_LOGW(TAG, "Bad bit %u in byte %u", bit, i); + return false; + } + } + } + if (!data.expect_mark(BIT_MARK_US)) { + ESP_LOGVV(TAG, "Footer fail"); + return false; + } + for (uint8_t offset = 0; offset < length; offset += SAMSUNG_AC_SECTION_LENGTH) { + if (stored_checksum(frame + offset) != section_checksum(frame + offset)) { + ESP_LOGW(TAG, "Checksum mismatch in section %u", offset / SAMSUNG_AC_SECTION_LENGTH); + return false; + } + } + + // In an extended frame the middle section is the timer block and the last one the second state section + uint8_t *state = this->state_.data(); + memcpy(state, frame, SAMSUNG_AC_SECTION_LENGTH); + memcpy(state + SAMSUNG_AC_SECTION_LENGTH, frame + length - SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH); + + this->swing_mode = from_code(SWINGS, get_field(state, SWING)); + this->target_temperature = get_field(state, TEMP) + SAMSUNG_AC_TEMP_MIN; + this->fan_mode = from_code(FANS, get_field(state, FAN)); + this->mode = + is_powered_on(state) ? from_code(MODES, get_field(state, MODE)) : climate::CLIMATE_MODE_OFF; + + ESP_LOGD(TAG, "Received %u byte frame, power %s", length, ONOFF(is_powered_on(state))); + this->publish_state(); + return true; +} + +} // namespace esphome::samsung diff --git a/esphome/components/samsung/samsung.h b/esphome/components/samsung/samsung.h new file mode 100644 index 0000000000..608cffd028 --- /dev/null +++ b/esphome/components/samsung/samsung.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate_ir/climate_ir.h" + +#include + +namespace esphome::samsung { + +static constexpr uint8_t SAMSUNG_AC_TEMP_MIN = 16; +static constexpr uint8_t SAMSUNG_AC_TEMP_MAX = 30; +// A frame is made of 7 byte sections; the normal state is two sections and a power change adds a timer section +static constexpr uint8_t SAMSUNG_AC_SECTION_LENGTH = 7; +static constexpr uint8_t SAMSUNG_AC_STATE_LENGTH = 2 * SAMSUNG_AC_SECTION_LENGTH; + +class SamsungClimate : public climate_ir::ClimateIR { + public: + SamsungClimate(); + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Send the given sections as one frame, each with its checksum filled in + void send_sections_(const uint8_t *const *sections, uint8_t count); + + // The last state sent or received; its power bits say whether the next frame has to switch power + std::array state_{}; +}; + +} // namespace esphome::samsung diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 07f86c1330..7b9f616460 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_OPERATION, CONF_OPTION, CONF_OPTIONS, - CONF_TRIGGER_ID, CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority @@ -37,12 +36,6 @@ select_ns = cg.esphome_ns.namespace("select") Select = select_ns.class_("Select", cg.EntityBase) SelectPtr = Select.operator("ptr") -# Triggers -SelectStateTrigger = select_ns.class_( - "SelectStateTrigger", - automation.Trigger.template(cg.StringRef, cg.size_t), -) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -63,11 +56,7 @@ _SELECT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSelectComponent), cv.GenerateID(): cv.declare_id(Select), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SelectStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), } ) ) @@ -101,9 +90,16 @@ async def setup_select_core_(var, config, *, options: list[str]): cg.add(var.traits.set_options_static(table, len(options))) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.StringRef, "x"), (cg.size_t, "i")], conf + # The callback carries the index; the automation gets the option text as well. + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index c2dc752ca5..9bef819e6d 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,17 +6,6 @@ namespace esphome::select { -class SelectStateTrigger final : public Trigger { - public: - explicit SelectStateTrigger(Select *parent) : parent_(parent) { - parent->add_on_state_callback( - [this](size_t index) { this->trigger(StringRef(this->parent_->option_at(index)), index); }); - } - - protected: - Select *parent_; -}; - template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 1440e07f76..79c6785be2 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,7 +113,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( SubEntities, @@ -421,16 +421,30 @@ async def multiply_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, template_) +TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_) + + +async def _value_list_table(values: list[Any]) -> MockObj: + """PROGMEM value table, ended by an empty entry so filters store no count.""" + fns = [await cg.templatable(x, [], cg.float_) for x in values] + # Identical lambdas (e.g. a YAML anchor) may keep static state, so only constants share. + return cg.shared_progmem_array( + "sensor_value_list", + TemplatableFloat, + [*fns, TemplatableFloat()], + share=not any(isinstance(v, Lambda) for v in values), + ) + + @FILTER_REGISTRY.register( "filter_out", FilterOutValueFilter, cv.Any(cv.templatable(cv.float_), [cv.templatable(cv.float_)]), ) -async def filter_out_filter_to_code(config, filter_id): +async def filter_out_filter_to_code(config: Any, filter_id: ID) -> MockObj: if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], cg.float_) for x in config] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) + return cg.new_Pvariable(filter_id, await _value_list_table(config)) QUANTILE_SCHEMA = cv.All( @@ -704,7 +718,9 @@ def _is_nan_only(values: Any) -> bool: ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA, ) -async def throttle_with_priority_filter_to_code(config, filter_id): +async def throttle_with_priority_filter_to_code( + config: ConfigType, filter_id: ID +) -> MockObj: values = config[CONF_VALUE] if not isinstance(values, list): values = [values] @@ -716,10 +732,8 @@ async def throttle_with_priority_filter_to_code(config, filter_id): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) - template_ = [await cg.templatable(x, [], cg.float_) for x in values] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ - ) + table = await _value_list_table(values) + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], table) HEARTBEAT_SCHEMA = cv.Schema( @@ -806,6 +820,9 @@ def validate_calibrate_linear(config): return config +LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3) + + @FILTER_REGISTRY.register( "calibrate_linear", CalibrateLinearFilter, @@ -832,9 +849,11 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + # The last segment always has a NaN boundary, so the filter needs no count. + table = cg.shared_progmem_array( + "sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions ) + return cg.new_Pvariable(filter_id, table) CONF_DEGREE = "degree" @@ -872,7 +891,11 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) + # The first entry holds the coefficient count, so the filter needs no count field. + table = cg.shared_progmem_array( + "sensor_calibrate_polynomial", cg.float_, [float(len(res)), *res] + ) + return cg.new_Pvariable(filter_id, table) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 732db5b0b4..9b027c4e11 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(mu optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { - int8_t accuracy = parent->get_accuracy_decimals(); +// ValueListFilter +bool ValueListFilter::value_matches_any_(float sensor_value) { + int8_t accuracy = this->parent_->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (size_t i = 0; i < count; i++) { - float fv = values[i].value(); + for (const TemplatableFn *v = this->values_; v->has_value(); v++) { + float fv = v->value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -261,13 +261,12 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { +// ThrottleWithPriorityFilter +optional ThrottleWithPriorityFilter::new_value(float value) { const uint32_t now = App.get_loop_component_start_time(); - if (last_input == 0 || now - last_input >= min_time_between_inputs || - value_list_matches_any(parent, value, values, count)) { - last_input = now; + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || + this->value_matches_any_(value)) { + this->last_input_ = now; return value; } return {}; @@ -415,19 +414,21 @@ void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { }); } -optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { - for (size_t i = 0; i < count; i++) { - if (!std::isfinite(functions[i][2]) || value < functions[i][2]) - return (value * functions[i][0]) + functions[i][1]; +// The calibration tables hold only floats, so aligned 32-bit loads from PROGMEM are safe on ESP8266. +optional CalibrateLinearFilter::new_value(float value) { + // Always returns: codegen ends the table with a segment whose boundary is NaN. + for (const auto *function = this->functions_;; function++) { + if (!std::isfinite((*function)[2]) || value < (*function)[2]) + return (value * (*function)[0]) + (*function)[1]; } - return NAN; } -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { +optional CalibratePolynomialFilter::new_value(float value) { float res = 0.0f; float x = 1.0f; - for (size_t i = 0; i < count; i++) { - res += x * coefficients[i]; + const auto count = static_cast(this->coefficients_[0]); + for (size_t i = 1; i <= count; i++) { + res += x * this->coefficients_[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 01949f45df..5ff99881de 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -330,36 +330,27 @@ class MultiplyFilter : public Filter { TemplatableFn multiplier_; }; -/// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); +// Value tables are read in place from flash, which ESP8266 only allows for whole words. +static_assert(sizeof(TemplatableFn) == sizeof(void *), "TemplatableFn must stay a single pointer"); -/** Base class for filters that compare sensor values against a fixed list of configured values. - * - * Templated on N (the number of values) so the list is stored inline in a std::array, - * avoiding heap allocation and the overhead of FixedVector. - * - * @tparam N Number of values in the filter list, set by code generation to match - * the exact number of values configured in YAML. - */ -template class ValueListFilter : public Filter { +/// Base class for filters that compare sensor values against a fixed list of configured values. +class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { - init_array_from(this->values_, values); - } + explicit ValueListFilter(const TemplatableFn *values) : values_(values) {} /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value) { - return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); - } + bool value_matches_any_(float sensor_value); - std::array, N> values_{}; + // Shared PROGMEM table ended by an empty entry, so no count is stored; entries are function + // pointers, so aligned 32-bit loads from flash are safe on ESP8266. + const TemplatableFn *values_; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -template class FilterOutValueFilter : public ValueListFilter { +class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} + explicit FilterOutValueFilter(const TemplatableFn *values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { if (this->value_matches_any_(value)) @@ -379,21 +370,13 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; -/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); - /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -template class ThrottleWithPriorityFilter : public ValueListFilter { +class ThrottleWithPriorityFilter : public ValueListFilter { public: - explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} + ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn *prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override { - return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, - this->min_time_between_inputs_); - } + optional new_value(float value) override; protected: uint32_t last_input_{0}; @@ -579,38 +562,24 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -/// Non-template helper for linear calibration (implementation in filter.cpp) -optional calibrate_linear_compute(const std::array *functions, size_t count, float value); - -/// N is set by code generation to match the exact number of calibration segments. -template class CalibrateLinearFilter : public Filter { +/// Piecewise linear calibration; the segments are a PROGMEM table shared by filters with the same data. +class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { - init_array_from(this->linear_functions_, linear_functions); - } - optional new_value(float value) override { - return calibrate_linear_compute(this->linear_functions_.data(), N, value); - } + explicit CalibrateLinearFilter(const std::array *functions) : functions_(functions) {} + optional new_value(float value) override; protected: - std::array, N> linear_functions_{}; + const std::array *functions_; // Last segment has a NaN boundary, which ends the table }; -/// Non-template helper for polynomial calibration (implementation in filter.cpp) -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); - -/// N is set by code generation to match the exact number of polynomial coefficients. -template class CalibratePolynomialFilter : public Filter { +/// Polynomial calibration; the coefficients are a PROGMEM table shared by filters with the same data. +class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients) { - init_array_from(this->coefficients_, coefficients); - } - optional new_value(float value) override { - return calibrate_polynomial_compute(this->coefficients_.data(), N, value); - } + explicit CalibratePolynomialFilter(const float *coefficients) : coefficients_(coefficients) {} + optional new_value(float value) override; protected: - std::array coefficients_{}; + const float *coefficients_; // [count, c0, c1, ...] }; class ClampFilter : public Filter { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index d512362fa0..9623eb21b3 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -277,10 +277,16 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); #ifdef USE_SERIAL_PROXY_TAP + const bool entering_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_PROTOCOL && mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL; const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; this->mode_ = mode; + if (entering_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_enabled(); + } + // Only for an explicit client request, not for reset_mode_() at the end of a session: // an ordinary disconnect says nothing about the device, whereas a client deliberately // asking for raw bytes usually precedes changing what the device is. @@ -426,6 +432,14 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: void SerialProxy::on_usb_connection_changed_(bool connected) { ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_SERIAL_PROXY_TAP + // Before telling clients, so a tap never acknowledges a frame from the old device after + // a client has been told it is gone. The subscriber and the mode stay: both belong to + // the client's session, and only the client knows whether that session is over. + if (!connected && this->tap_ != nullptr) { + this->tap_->on_device_disconnected(); + } +#endif #ifdef USE_API if (api::global_api_server == nullptr) { return; diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 1a406bc089..2b8f933f88 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -89,11 +89,20 @@ class SerialProxyTap { /// subscriber holds the port; with one attached, the port mode alone decides. virtual bool tap_needs_port() const = 0; + /// A client turned protocol handling on for this port. The tap has seen none of the + /// traffic since it was last on, so anything it remembers from then is stale. + virtual void on_protocol_enabled() = 0; + /// A client explicitly turned protocol handling off for this port. Distinct from the /// automatic reset when a session ends: this one means a client intends to do something /// else with the device -- reflash it, most likely -- so anything the tap believes about /// it should be treated as suspect. virtual void on_protocol_disabled() = 0; + + /// The device behind the port went away -- unplugged, or its power was cut. Whatever + /// session the tap had observed ended with it; the device that appears next starts from + /// scratch and may not even be the same one. Only a USB UART can report this. + virtual void on_device_disconnected() = 0; }; #endif diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py new file mode 100644 index 0000000000..4f2b026a5e --- /dev/null +++ b/esphome/components/sh1122_base/__init__.py @@ -0,0 +1,43 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@pl4nkton"] +DOMAIN = "sh1122_base" + +sh1122_base_ns = cg.esphome_ns.namespace("sh1122_base") +SH1122 = sh1122_base_ns.class_("SH1122", cg.PollingComponent, display.DisplayBuffer) +SH1122Model = sh1122_base_ns.enum("SH1122Model") + +MODELS = { + "SH1122_256X64": SH1122Model.SH1122_MODEL_256_64, +} + +SH1122_MODEL = cv.enum(MODELS, upper=True, space="_") + +SH1122_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.Optional(CONF_MODEL, default="SH1122_256X64"): SH1122_MODEL, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, + } +).extend(cv.polling_component_schema("1s")) + + +async def setup_sh1122(var: MockObj, config: ConfigType) -> None: + await display.register_display(var, config) + + cg.add(var.set_model(config[CONF_MODEL])) + if (reset_pin := config.get(CONF_RESET_PIN)) is not None: + reset = await cg.gpio_pin_expression(reset_pin) + cg.add(var.set_reset_pin(reset)) + cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/sh1122_base/sh1122_base.cpp b/esphome/components/sh1122_base/sh1122_base.cpp new file mode 100644 index 0000000000..72d5e44235 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.cpp @@ -0,0 +1,146 @@ +#include "sh1122_base.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sh1122_base { + +ESPHOME_LOG_TAG(TAG, "sh1122"); + +static constexpr uint8_t SH1122_MAX_CONTRAST = 255; +static constexpr uint8_t SH1122_COLORMASK = 0x0f; +static constexpr uint8_t SH1122_COLORSHIFT = 4; +static constexpr uint8_t SH1122_PIXELSPERBYTE = 2; + +static constexpr uint8_t SH1122_SETLOWCOLUMNADDRESS = 0x00; +static constexpr uint8_t SH1122_SETHIGHCOLUMNADDRESS = 0x10; +static constexpr uint8_t SH1122_SETDISCHARGEVSLLEVEL = 0x30; +static constexpr uint8_t SH1122_SETDISPLAYSTARTLINE = 0x40; +static constexpr uint8_t SH1122_SETCONTRASTCURRENT = 0x81; +static constexpr uint8_t SH1122_SETSEGMENTREMAP = 0xA0; +static constexpr uint8_t SH1122_SETDISPLAYOFFON = 0xA4; +static constexpr uint8_t SH1122_SETNORMALDISPLAY = 0xA6; +static constexpr uint8_t SH1122_SETMULTIPLEXRATIO = 0xA8; +static constexpr uint8_t SH1122_DCDCSETTING = 0xAD; +static constexpr uint8_t SH1122_SETDISPLAYOFF = 0xAE; +static constexpr uint8_t SH1122_SETDISPLAYON = 0xAF; +static constexpr uint8_t SH1122_SETROWADDRESS = 0xB0; +static constexpr uint8_t SH1122_SETSCANDIRECTION = 0xC0; +static constexpr uint8_t SH1122_SETDISPLAYOFFSET = 0xD3; +static constexpr uint8_t SH1122_SETCLOCKDIVIDER = 0xD5; +static constexpr uint8_t SH1122_SETDISCHARGEPRECHARGEPERIOD = 0xD9; +static constexpr uint8_t SH1122_SETVCOMDESELECTLEVEL = 0xDB; +static constexpr uint8_t SH1122_SETVSEGMLEVEL = 0xDC; + +void SH1122::setup() { + this->init_internal_(this->get_buffer_length_()); + if (this->buffer_ == nullptr) { + this->mark_failed(); + return; + } + + this->turn_off(); + this->command2_(SH1122_SETCLOCKDIVIDER, 0x50); + this->command2_(SH1122_SETMULTIPLEXRATIO, 0x3F); + this->command2_(SH1122_SETDISPLAYOFFSET, 0x00); + this->command2_(SH1122_SETROWADDRESS, 0x00); + this->command_(SH1122_SETDISPLAYSTARTLINE | 32); // the panel's rows start half way down the 128 line RAM + this->command_(SH1122_SETDISCHARGEVSLLEVEL); + this->command2_(SH1122_DCDCSETTING, 0x81); + this->command_(SH1122_SETSEGMENTREMAP | 0x01); // column 0 on the right + this->command_(SH1122_SETSCANDIRECTION | 0x08); // rows scanned bottom up + this->command2_(SH1122_SETDISCHARGEPRECHARGEPERIOD, 0x28); + this->command2_(SH1122_SETVCOMDESELECTLEVEL, 0x35); + this->command2_(SH1122_SETVSEGMLEVEL, 0x35); + this->command_(SH1122_SETNORMALDISPLAY); + this->command_(SH1122_SETHIGHCOLUMNADDRESS); + this->command_(SH1122_SETLOWCOLUMNADDRESS); + this->command_(SH1122_SETDISPLAYOFFON); + this->set_brightness(this->brightness_); + this->display(); // the buffer starts cleared; push it so no power-on garbage shows + this->turn_on(); +} +void SH1122::display() { + // Return the RAM pointer to the top left corner in one transfer, then stream the frame + const uint8_t set_address[] = {SH1122_SETHIGHCOLUMNADDRESS, SH1122_SETLOWCOLUMNADDRESS, SH1122_SETROWADDRESS, 0x00}; + this->write_command(set_address, sizeof(set_address)); + this->write_display_data(); +} +void SH1122::update() { + this->do_update_(); + this->display(); +} +void SH1122::set_brightness(float brightness) { + this->brightness_ = clamp(brightness, 0.0F, 1.0F); + this->command2_(SH1122_SETCONTRASTCURRENT, int(SH1122_MAX_CONTRAST * (this->brightness_))); +} +void SH1122::turn_on() { + this->command_(SH1122_SETDISPLAYON); + this->is_on_ = true; +} +void SH1122::turn_off() { + this->command_(SH1122_SETDISPLAYOFF); + this->is_on_ = false; +} +int SH1122::get_height_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 64; + default: + return 0; + } +} +int SH1122::get_width_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 256; + default: + return 0; + } +} +size_t SH1122::get_buffer_length_() { + return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / SH1122_PIXELSPERBYTE; +} +void HOT SH1122::draw_absolute_pixel_internal(int x, int y, Color color) { + const int width = this->get_width_internal(); + if (x >= width || x < 0 || y >= this->get_height_internal() || y < 0) + return; + // Two pixels per byte, the left one in the high nibble + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color) & SH1122_COLORMASK; + const uint16_t pos = (x / SH1122_PIXELSPERBYTE) + (y * width / SH1122_PIXELSPERBYTE); + const uint8_t shift = (1u - (x % SH1122_PIXELSPERBYTE)) * SH1122_COLORSHIFT; + this->buffer_[pos] = (this->buffer_[pos] & (static_cast(~SH1122_COLORMASK) >> shift)) | (color4 << shift); +} +void SH1122::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color); + const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT); + memset(this->buffer_, fill, this->get_buffer_length_()); +} +void SH1122::init_reset_() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(1); + // Trigger Reset + this->reset_pin_->digital_write(false); + delay(10); + // Wake up + this->reset_pin_->digital_write(true); + } +} +const LogString *SH1122::model_str_() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return LOG_STR("SH1122 256x64"); + default: + return LOG_STR("Unknown"); + } +} + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_base/sh1122_base.h b/esphome/components/sh1122_base/sh1122_base.h new file mode 100644 index 0000000000..c3e4719d39 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/display/display_buffer.h" + +namespace esphome::sh1122_base { + +enum SH1122Model : uint8_t { + SH1122_MODEL_256_64 = 0, +}; + +class SH1122 : public display::DisplayBuffer { + public: + void setup() override; + + void display(); + + void update() override; + + void set_model(SH1122Model model) { this->model_ = model; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void init_brightness(float brightness) { this->brightness_ = brightness; } + void set_brightness(float brightness); + bool is_on() { return this->is_on_; } + void turn_on(); + void turn_off(); + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + void fill(Color color) override; + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_GRAYSCALE; } + + protected: + /// Sends a command byte and its arguments; the SH1122 takes command arguments with D/C low as well + virtual void write_command(const uint8_t *bytes, size_t len) = 0; + virtual void write_display_data() = 0; + void command_(uint8_t value) { this->write_command(&value, 1); } + void command2_(uint8_t value, uint8_t data) { + const uint8_t bytes[2] = {value, data}; + this->write_command(bytes, 2); + } + void init_reset_(); + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + + int get_height_internal() override; + int get_width_internal() override; + size_t get_buffer_length_(); + const LogString *model_str_(); + + GPIOPin *reset_pin_{nullptr}; + float brightness_{1.0}; + SH1122Model model_{SH1122_MODEL_256_64}; + bool is_on_{false}; +}; + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_spi/__init__.py b/esphome/components/sh1122_spi/__init__.py new file mode 100644 index 0000000000..4ed7022267 --- /dev/null +++ b/esphome/components/sh1122_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sh1122_spi" diff --git a/esphome/components/sh1122_spi/display.py b/esphome/components/sh1122_spi/display.py new file mode 100644 index 0000000000..36acb9fd59 --- /dev/null +++ b/esphome/components/sh1122_spi/display.py @@ -0,0 +1,37 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import sh1122_base, spi +import esphome.config_validation as cv +from esphome.const import CONF_DC_PIN, CONF_ID, CONF_LAMBDA, CONF_PAGES +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@pl4nkton"] + +AUTO_LOAD = ["sh1122_base"] +DEPENDENCIES = ["spi"] + +sh1122_spi = cg.esphome_ns.namespace("sh1122_spi") +SPISH1122 = sh1122_spi.class_("SPISH1122", sh1122_base.SH1122, spi.SPIDevice) + +CONFIG_SCHEMA = cv.All( + sh1122_base.SH1122_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SPISH1122), + cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, + } + ).extend(spi.spi_device_schema(cs_pin_required=False)), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), +) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + DOMAIN, require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) + var = cg.new_Pvariable(config[CONF_ID], dc) + await sh1122_base.setup_sh1122(var, config) + await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/sh1122_spi/sh1122_spi.cpp b/esphome/components/sh1122_spi/sh1122_spi.cpp new file mode 100644 index 0000000000..2e925aea29 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.cpp @@ -0,0 +1,42 @@ +#include "sh1122_spi.h" +#include "esphome/core/log.h" + +namespace esphome::sh1122_spi { + +ESPHOME_LOG_TAG(TAG, "sh1122_spi"); + +void SPISH1122::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + + this->init_reset_(); + delay(500); // NOLINT + SH1122::setup(); +} +void SPISH1122::dump_config() { + LOG_DISPLAY("", "SPI SH1122", this); + ESP_LOGCONFIG(TAG, + " Model: %s\n" + " Initial Brightness: %.2f", + LOG_STR_ARG(this->model_str_()), this->brightness_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} +// enable() and disable() drive the CS pin. The D/C setup time is a few tens of nanoseconds, so the +// GPIO write followed by the CS edge already satisfies it; no delay is needed. +void SPISH1122::write_command(const uint8_t *bytes, size_t len) { + this->dc_pin_->digital_write(false); + this->enable(); + this->write_array(bytes, len); + this->disable(); +} +void HOT SPISH1122::write_display_data() { + this->dc_pin_->digital_write(true); + this->enable(); + this->write_array(this->buffer_, this->get_buffer_length_()); + this->disable(); +} + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/sh1122_spi/sh1122_spi.h b/esphome/components/sh1122_spi/sh1122_spi.h new file mode 100644 index 0000000000..f5da209fe2 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/sh1122_base/sh1122_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::sh1122_spi { + +class SPISH1122 final : public sh1122_base::SH1122, + public spi::SPIDevice { + public: + explicit SPISH1122(GPIOPin *dc_pin) : dc_pin_(dc_pin) {} + + void setup() override; + + void dump_config() override; + + protected: + void write_command(const uint8_t *bytes, size_t len) override; + void write_display_data() override; + + GPIOPin *dc_pin_; +}; + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/siemens_ira211/__init__.py b/esphome/components/siemens_ira211/__init__.py new file mode 100644 index 0000000000..1407aff81e --- /dev/null +++ b/esphome/components/siemens_ira211/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@fonix232"] +DOMAIN = "siemens_ira211" diff --git a/esphome/components/siemens_ira211/climate.py b/esphome/components/siemens_ira211/climate.py new file mode 100644 index 0000000000..16345e70f1 --- /dev/null +++ b/esphome/components/siemens_ira211/climate.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +siemens_ira211_ns = cg.esphome_ns.namespace("siemens_ira211") +SiemensIRA211Climate = siemens_ira211_ns.class_( + "SiemensIRA211Climate", climate_ir.ClimateIR +) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SiemensIRA211Climate) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("ira211") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/siemens_ira211/siemens_ira211.cpp b/esphome/components/siemens_ira211/siemens_ira211.cpp new file mode 100644 index 0000000000..e332134eac --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.cpp @@ -0,0 +1,167 @@ +#include "siemens_ira211.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::siemens_ira211 { + +using namespace remote_base; + +struct PresetMapping { + climate::ClimatePreset preset; + IRA211Mode mode; +}; + +static const PresetMapping PRESETS[] PROGMEM = { + {climate::CLIMATE_PRESET_COMFORT, IRA211Mode::IRA211_MODE_COMFORT}, + {climate::CLIMATE_PRESET_ECO, IRA211Mode::IRA211_MODE_TIMER}, +}; + +struct FanMapping { + climate::ClimateFanMode fan_mode; + IRA211Fan fan; +}; + +static const FanMapping FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, IRA211Fan::IRA211_FAN_AUTO}, + {climate::CLIMATE_FAN_LOW, IRA211Fan::IRA211_FAN_LOW}, + {climate::CLIMATE_FAN_MEDIUM, IRA211Fan::IRA211_FAN_MEDIUM}, + {climate::CLIMATE_FAN_HIGH, IRA211Fan::IRA211_FAN_HIGH}, +}; + +// The tables live in flash, so entries are copied out before use +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +// The first entry of each table is the fallback for values the other side does not know +static IRA211Mode to_ira211_mode(climate::ClimatePreset preset) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.preset == preset) + return entry.mode; + } + return load_entry(PRESETS[0]).mode; +} + +static climate::ClimatePreset to_climate_preset(IRA211Mode mode) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.mode == mode) + return entry.preset; + } + return load_entry(PRESETS[0]).preset; +} + +static IRA211Fan to_ira211_fan(climate::ClimateFanMode fan_mode) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan_mode == fan_mode) + return entry.fan; + } + return load_entry(FANS[0]).fan; +} + +static climate::ClimateFanMode to_climate_fan_mode(IRA211Fan fan) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan == fan) + return entry.fan_mode; + } + return load_entry(FANS[0]).fan_mode; +} + +void SiemensIRA211Climate::setup() { + // The thermostat decides between heating and cooling itself, so one mode stands for both + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) { + this->modes_.erase(climate::CLIMATE_MODE_HEAT); + this->modes_.erase(climate::CLIMATE_MODE_COOL); + } + climate_ir::ClimateIR::setup(); + // Match the restored state, so the first command after a reboot does not send a needless POWER frame + this->is_on_ = this->mode != climate::CLIMATE_MODE_OFF; +} + +climate::ClimateMode SiemensIRA211Climate::active_mode_() const { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) + return climate::CLIMATE_MODE_HEAT_COOL; + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) + return climate::CLIMATE_MODE_HEAT; + return climate::CLIMATE_MODE_COOL; +} + +void SiemensIRA211Climate::transmit_frame_(IRA211Command command, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(this->target_temperature); + data.mode = mode; + data.fan = fan; + this->transmit_(data); +} + +void SiemensIRA211Climate::transmit_state() { + const IRA211Mode mode = to_ira211_mode(this->preset.value_or(climate::CLIMATE_PRESET_COMFORT)); + const IRA211Fan fan = to_ira211_fan(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + // Show the preset that is actually sent, so a fresh entity does not stay on "none" + this->preset = to_climate_preset(mode); + + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, IRA211Mode::IRA211_MODE_PROTECTION, fan); + this->is_on_ = false; + return; + } + if (!this->is_on_) { + // Turning on takes a POWER frame before the state is sent + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, mode, fan); + this->is_on_ = true; + } + this->transmit_frame_(IRA211Command::IRA211_COMMAND_SYNC, mode, fan); +} + +void SiemensIRA211Climate::apply_mode_(IRA211Mode mode) { + // The thermostat reports "off" as the PROTECTION mode + if (mode == IRA211Mode::IRA211_MODE_PROTECTION) { + this->mode = climate::CLIMATE_MODE_OFF; + this->is_on_ = false; + return; + } + if (!this->is_on_) { + this->mode = this->active_mode_(); + this->is_on_ = true; + } + this->preset = to_climate_preset(mode); +} + +bool SiemensIRA211Climate::on_receive(RemoteReceiveData data) { + auto decoded = IRA211Protocol().decode(data); + if (!decoded.has_value()) + return false; + + const IRA211Data &frame = *decoded; + switch (frame.command) { + case IRA211Command::IRA211_COMMAND_SYNC: + case IRA211Command::IRA211_COMMAND_POWER: + this->apply_mode_(frame.mode); + this->target_temperature = frame.get_temperature(); + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + case IRA211Command::IRA211_COMMAND_TEMP_UP: + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + this->target_temperature = frame.get_temperature(); + break; + case IRA211Command::IRA211_COMMAND_MODE: + this->apply_mode_(frame.mode); + break; + case IRA211Command::IRA211_COMMAND_FAN: + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + default: + return false; + } + + this->publish_state(); + return true; +} + +} // namespace esphome::siemens_ira211 diff --git a/esphome/components/siemens_ira211/siemens_ira211.h b/esphome/components/siemens_ira211/siemens_ira211.h new file mode 100644 index 0000000000..dcba2ddcb9 --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/ira211_protocol.h" + +namespace esphome::siemens_ira211 { + +class SiemensIRA211Climate final : public climate_ir::ClimateIR { + public: + void setup() override; + SiemensIRA211Climate() + : climate_ir::ClimateIR(remote_base::IRA211_MIN_TEMPERATURE, remote_base::IRA211_MAX_TEMPERATURE, 0.5f, false, + false, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {}, {climate::CLIMATE_PRESET_COMFORT, climate::CLIMATE_PRESET_ECO}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void transmit_frame_(remote_base::IRA211Command command, remote_base::IRA211Mode mode, remote_base::IRA211Fan fan); + void apply_mode_(remote_base::IRA211Mode mode); + climate::ClimateMode active_mode_() const; + + bool is_on_{false}; +}; + +} // namespace esphome::siemens_ira211 diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index cc5f672ad5..effa7d864f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -269,7 +269,9 @@ async def to_code(config: ConfigType) -> None: # consumers that called the matching require_ function. FILTER_SOURCE_FILES = filter_source_files_from_defines( { + "lwip_raw_common_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "lwip_raw_tcp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", + "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 1b5ea9ebcd..e2b5de6f10 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -144,6 +144,9 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index f4597f2522..1d50555cd1 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -20,6 +20,16 @@ #define IPPROTO_IP 0 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +#define IP_ADD_MEMBERSHIP 3 +#define IP_DROP_MEMBERSHIP 4 + +// NOLINTNEXTLINE(readability-identifier-naming) +struct ip_mreq { + struct in_addr imr_multiaddr; + struct in_addr imr_interface; +}; #if LWIP_IPV6 #define AF_INET6 10 diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp new file mode 100644 index 0000000000..4ec0a37355 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -0,0 +1,116 @@ +#include "lwip_raw_common_impl.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +namespace esphome::socket { + +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen) { + if (family == AF_INET) { + if (*addrlen < sizeof(struct sockaddr_in)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin_family = AF_INET; + *addrlen = addr->sin_len = sizeof(struct sockaddr_in); + addr->sin_port = htons(port_host); + inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); + return 0; + } +#if LWIP_IPV6 + if (family == AF_INET6) { + if (*addrlen < sizeof(struct sockaddr_in6)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin6_family = AF_INET6; + *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); + addr->sin6_port = htons(port_host); + // AF_INET6 sockets may receive IPv4 packets; convert to IPv4-mapped IPv6. + if (IP_IS_V4(ip)) { + ip_addr_t mapped; + ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); + } else { + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); + } + return 0; + } +#endif + errno = EAFNOSUPPORT; + return -1; +} + +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + // headers.h defines sockaddr and sockaddr_in with the same size, so this covers AF_INET + if (addrlen < sizeof(struct sockaddr)) + return false; + // Zero the whole struct — the IPv6 zone byte would otherwise be stack garbage + memset(ip, 0, sizeof(*ip)); + if (addr->sa_family == AF_INET) { + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + ip_2_ip4(ip)->addr = addr4->sin_addr.s_addr; + return true; + } +#if LWIP_IPV6 + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V6); + memcpy(&ip_2_ip6(ip)->addr, &addr6->sin6_addr.un.u8_addr, 16); + // Unmap ::ffff:a.b.c.d so replies to recvfrom addresses route as IPv4 + if (ip6_addr_isipv4mappedipv6(ip_2_ip6(ip))) { + unmap_ipv4_mapped_ipv6(ip_2_ip4(ip), ip_2_ip6(ip)); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + } + return true; + } +#endif + return false; +} + +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port) { + if (!sockaddr_to_lwip(addr, addrlen, ip, port)) + return false; +#if LWIP_IPV6 + // Only promote wildcard binds — a specific address must keep filtering + if (family == AF_INET6 && ip_addr_isany_val(*ip)) + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +#endif + return true; +} + +int lwip_err_to_errno(err_t err) { + switch (err) { + case ERR_MEM: + return ENOMEM; + case ERR_BUF: + return EAGAIN; // transient, e.g. no free local port + case ERR_RTE: + return EHOSTUNREACH; // no route, e.g. no address yet + case ERR_VAL: + case ERR_ARG: + return EINVAL; + case ERR_USE: + return EADDRINUSE; + case ERR_ISCONN: + return EISCONN; + default: + return EIO; + } +} + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_common_impl.h b/esphome/components/socket/lwip_raw_common_impl.h new file mode 100644 index 0000000000..49e3a23f23 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.h @@ -0,0 +1,53 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include "headers.h" +#include "lwip/ip.h" + +namespace esphome::socket { + +// ---- LWIP thread safety ---- +// +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority +// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). They can preempt main-loop code at any point. +// +// Without locking, this causes race conditions between recv_fn and read() on the +// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing +// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. +// +// On ESP8266, lwip callbacks run from the SYS context which cooperates with user +// code (CONT context) — they never preempt each other, so no locking is needed. +// +// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). +// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end +// (Ethernet). On ESP8266, it's a no-op. +// +// Each .cpp file that needs locking defines its own LWIP_LOCK() macro: +// #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard +// This is a per-TU convenience macro, not defined here to avoid macro leaking. + +/// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// TCP callers pass ntohs(pcb port) to preserve historical getpeername/getsockname output. +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen); + +/// Convert sockaddr to lwip ip_addr_t and host-order port. +/// For IPv6, sets type to IPADDR_TYPE_V6 — correct for sendto destinations. +/// Bind paths must use sockaddr_to_lwip_bind() instead. +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); + +/// sockaddr_to_lwip variant for bind: promotes AF_INET6 sockets to +/// IPADDR_TYPE_ANY so they accept both IPv4 and IPv6 (dual-stack). +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port); + +/// errno for a failed lwip call +int lwip_err_to_errno(err_t err); + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2b21291c30..77ff0f4735 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#include "lwip_raw_common_impl.h" #ifdef USE_OTA_PLATFORM_ESPHOME extern "C" void esphome_wake_ota_component_any_context(); @@ -24,23 +25,9 @@ extern "C" void esphome_wake_ota_component_any_context(); namespace esphome::socket { -// ---- LWIP thread safety ---- -// -// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. -// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority -// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk -// async_context_threadsafe_background.c). They can preempt main-loop code at any point. -// -// Without locking, this causes race conditions between recv_fn and read() on the -// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing -// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. -// -// On ESP8266, lwip callbacks run from the SYS context which cooperates with user -// code (CONT context) — they never preempt each other, so no locking is needed. -// -// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). -// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end -// (Ethernet). On ESP8266, it's a no-op. +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT ESPHOME_LOG_TAG(TAG, "socket"); @@ -54,27 +41,6 @@ static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); static inline void yield_to_sys() {} #endif -// errno for a failed tcp_* call -static int lwip_err_to_errno(err_t err) { - switch (err) { - case ERR_MEM: - return ENOMEM; - case ERR_BUF: - return EAGAIN; // transient, e.g. no free local port - case ERR_RTE: - return EHOSTUNREACH; // no route, e.g. no address yet - case ERR_VAL: - case ERR_ARG: - return EINVAL; - case ERR_USE: - return EADDRINUSE; - case ERR_ISCONN: - return EISCONN; - default: - return EIO; - } -} - // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -125,33 +91,14 @@ LWIPRawCommon::~LWIPRawCommon() { } } -bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const { - if (name == nullptr) { - errno = EINVAL; - return false; - } -#if LWIP_IPV6 - if (this->family_ == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) { - errno = EINVAL; - return false; - } - auto *addr6 = reinterpret_cast(name); - *port = ntohs(addr6->sin6_port); - inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr); - // ANY lets bind() accept both families; connect() picks the concrete type - IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, + bool for_bind) const { + if (name != nullptr && name->sa_family == this->family_ && + (for_bind ? sockaddr_to_lwip_bind(this->family_, name, addrlen, ip, port) + : sockaddr_to_lwip(name, addrlen, ip, port))) return true; - } -#endif - if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) { - errno = EINVAL; - return false; - } - auto *addr4 = reinterpret_cast(name); - *port = ntohs(addr4->sin_port); - ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr); - return true; + errno = EINVAL; + return false; } int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { @@ -162,7 +109,7 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { } ip_addr_t ip; uint16_t port; - if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) { + if (!this->sockaddr2ip_(name, addrlen, &ip, &port, true)) { return -1; } LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); @@ -357,43 +304,8 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle } int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { - if (this->family_ == AF_INET) { - if (*addrlen < sizeof(struct sockaddr_in)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in *addr = reinterpret_cast(name); - addr->sin_family = AF_INET; - *addrlen = addr->sin_len = sizeof(struct sockaddr_in); - addr->sin_port = port; - inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); - return 0; - } -#if LWIP_IPV6 - else if (this->family_ == AF_INET6) { - if (*addrlen < sizeof(struct sockaddr_in6)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in6 *addr = reinterpret_cast(name); - addr->sin6_family = AF_INET6; - *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); - addr->sin6_port = port; - - // AF_INET6 sockets are bound to IPv4 as well, so we may encounter IPv4 addresses that must be converted to IPv6. - if (IP_IS_V4(ip)) { - ip_addr_t mapped; - ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); - } else { - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); - } - return 0; - } -#endif - return -1; + // lwip pcb ports are host order; ntohs preserves historical byte-swapped sin_port output + return lwip_ip_to_sockaddr(this->family_, ip, ntohs(port), name, addrlen); } // ---- LWIPRawImpl methods ---- @@ -462,20 +374,10 @@ int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) { } ip_addr_t ip; uint16_t port; - if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) { + // Concrete type for tcp_connect; a remembered IPv4 peer arrives v4-mapped and is unmapped + if (!this->sockaddr2ip_(addr, addrlen, &ip, &port, false)) { return -1; } -#if LWIP_IPV6 - // tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped - if (IP_IS_ANY_TYPE_VAL(ip)) { - if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) { - unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip)); - IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4); - } else { - IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6); - } - } -#endif LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn); if (err != ERR_OK) { @@ -965,11 +867,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { return ERR_OK; } -// ---- Factory functions ---- +// ---- TCP Factory functions ---- std::unique_ptr socket(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } @@ -989,7 +891,7 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_listen(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index ed8057e289..69158cc8fe 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -50,8 +50,9 @@ class LWIPRawCommon { protected: int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); - /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch - bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const; + /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch. + /// for_bind keeps an IPv6 wildcard dual stack, otherwise the type is concrete. + bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, bool for_bind) const; // Member ordering optimized to minimize padding on 32-bit systems struct tcp_pcb *pcb_; diff --git a/esphome/components/socket/lwip_raw_udp_impl.cpp b/esphome/components/socket/lwip_raw_udp_impl.cpp new file mode 100644 index 0000000000..dceba802ae --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.cpp @@ -0,0 +1,333 @@ +#include "socket.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" +#include "lwip_raw_common_impl.h" + +#include "lwip/igmp.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +#define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT + +// ---- LWIPRawUDPSendImpl (send-only) methods ---- + +LWIPRawUDPSendImpl::~LWIPRawUDPSendImpl() { + // Guard avoids acquiring the lwip lock when already closed + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPSendImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (name == nullptr) { + errno = EINVAL; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!sockaddr_to_lwip_bind(this->family_, name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } + err_t err = udp_bind(this->pcb_, &ip, port); + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return 0; +} + +int LWIPRawUDPSendImpl::close() { + LWIP_LOCK(); + return this->close_internal_locked_(); +} + +int LWIPRawUDPSendImpl::close_internal_locked_() { + // Caller must hold LWIP_LOCK + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + udp_remove(this->pcb_); + this->pcb_ = nullptr; + return 0; +} + +int LWIPRawUDPSendImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { + // UDP recv callback provides port in host byte order + return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); +} + +ssize_t LWIPRawUDPSendImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, + socklen_t addrlen) { + (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking + if (buf == nullptr || dest_addr == nullptr) { + errno = EINVAL; + return -1; + } + + // pbuf_alloc takes u16_t length; reject oversized packets + if (len > UINT16_MAX) { + errno = EMSGSIZE; + return -1; + } + + ip_addr_t dst_ip; + uint16_t dst_port; + if (!sockaddr_to_lwip(dest_addr, addrlen, &dst_ip, &dst_port)) { + errno = EINVAL; + return -1; + } + + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + + // Allocate pbuf and copy data + struct pbuf *pb = pbuf_alloc(PBUF_TRANSPORT, (uint16_t) len, PBUF_RAM); + if (pb == nullptr) { + errno = ENOMEM; + return -1; + } + memcpy(pb->payload, buf, len); + + err_t err = udp_sendto(this->pcb_, pb, &dst_ip, dst_port); + pbuf_free(pb); + + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return (ssize_t) len; +} + +int LWIPRawUDPSendImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + // Effective only where lwip is built with SO_REUSE=1 (ESP8266 yes, RP2040 currently no) + if (*reinterpret_cast(optval)) { + ip_set_option(this->pcb_, SOF_REUSEADDR); + } else { + ip_reset_option(this->pcb_, SOF_REUSEADDR); + } + return 0; + } + if (level == SOL_SOCKET && optname == SO_BROADCAST) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + int val = *reinterpret_cast(optval); + if (val) { + ip_set_option(this->pcb_, SOF_BROADCAST); + } else { + ip_reset_option(this->pcb_, SOF_BROADCAST); + } + return 0; + } + if (level == IPPROTO_IP && (optname == IP_ADD_MEMBERSHIP || optname == IP_DROP_MEMBERSHIP)) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr{mreq->imr_multiaddr.s_addr}; + ip4_addr_t ifaddr{mreq->imr_interface.s_addr}; + err_t err = + optname == IP_ADD_MEMBERSHIP ? igmp_joingroup(&ifaddr, &multiaddr) : igmp_leavegroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen == nullptr || *optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + *reinterpret_cast(optval) = ip_get_option(this->pcb_, SOF_REUSEADDR) ? 1 : 0; + *optlen = sizeof(int); + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::setblocking(bool blocking) { + if (blocking) { + // blocking operation not supported on raw lwip + errno = EINVAL; + return -1; + } + return 0; +} + +// ---- LWIPRawUDPImpl methods ---- + +LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb) : LWIPRawUDPSendImpl(family, pcb) { + // Registered here (not in bind) so unbound client sockets can receive replies + udp_recv(this->pcb_, LWIPRawUDPImpl::s_recv_fn, this); +} + +LWIPRawUDPImpl::~LWIPRawUDPImpl() { + // Flush rx queue and unregister callback before base destructor removes pcb + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPImpl::close() { + LWIP_LOCK(); + // Unregister recv callback before removing pcb + if (this->pcb_ != nullptr) { + udp_recv(this->pcb_, nullptr, nullptr); + } + // Flush queued rx packets; slots within rx_count_ always hold a live pbuf + for (; this->rx_count_ > 0; this->rx_count_--) { + pbuf_free(this->rx_queue_[this->rx_read_idx_].pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + } + // close_internal_locked_() returns EBADF if already closed, which is fine from destructor + return this->close_internal_locked_(); +} + +ssize_t LWIPRawUDPImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } + +ssize_t LWIPRawUDPImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { + if (buf == nullptr && len > 0) { + errno = EINVAL; + return -1; + } + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (this->rx_count_ == 0) { + errno = EWOULDBLOCK; + return -1; + } + + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + // On address conversion failure, still consume the packet — the failure is + // deterministic (family_ and *addrlen), so keeping it would wedge the queue + ssize_t ret = -1; + if (src_addr == nullptr || addrlen == nullptr || + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) == 0) { + ret = (ssize_t) std::min(len, (size_t) pkt.pb->tot_len); + pbuf_copy_partial(pkt.pb, buf, ret, 0); + } + pbuf_free(pkt.pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + this->rx_count_--; + return ret; +} + +void LWIPRawUDPImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + auto *self = reinterpret_cast(arg); + self->recv_fn_(p, addr, port); +} + +// LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). +// No heap allocation allowed — malloc is not IRQ-safe (see #14687). +// No LWIP_LOCK() needed — lwip core already holds the async_context lock. +void LWIPRawUDPImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + if (p == nullptr) + return; + + // Check if queue is full + if (this->rx_count_ >= UDP_RX_QUEUE_SIZE) { + // Drop packet — queue full. Can't log from IRQ context, so count it + // (saturating) for consumers to surface via get_rx_dropped(). + if (this->rx_dropped_ != UINT16_MAX) + this->rx_dropped_++; + pbuf_free(p); + return; + } + + // Enqueue the packet + uint8_t write_idx = (this->rx_read_idx_ + this->rx_count_) & UDP_RX_MASK; + auto &slot = this->rx_queue_[write_idx]; + slot.pb = p; + slot.src_addr = *addr; + slot.src_port = port; + this->rx_count_++; + + esphome::wake_loop_any_context(); +} + +// ---- UDP Factory functions ---- + +static struct udp_pcb *new_udp_pcb(int domain) { +#if LWIP_IPV6 + return udp_new_ip_type(domain == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); +#else + return udp_new(); +#endif +} + +std::unique_ptr socket_udp_send(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + return make_unique((sa_family_t) domain, pcb); +} + +std::unique_ptr socket_udp(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + // Ctor registers the recv callback under the lock held here + return make_unique((sa_family_t) domain, pcb); +} + +#undef LWIP_LOCK + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_udp_impl.h b/esphome/components/socket/lwip_raw_udp_impl.h new file mode 100644 index 0000000000..268515106f --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.h @@ -0,0 +1,113 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "headers.h" +#include "lwip/ip.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +/// Send-only UDP socket implementation for LWIP raw API. +/// Non-virtual, concrete type. Uses lwip/udp.h raw API. +/// No receive capability — use LWIPRawUDPImpl for sockets that need to receive. +class LWIPRawUDPSendImpl { + public: + /// The pcb is allocated by the factory (like the TCP impl); never null here. + LWIPRawUDPSendImpl(sa_family_t family, struct udp_pcb *pcb) : pcb_(pcb), family_(family) {} + ~LWIPRawUDPSendImpl(); + LWIPRawUDPSendImpl(const LWIPRawUDPSendImpl &) = delete; + LWIPRawUDPSendImpl &operator=(const LWIPRawUDPSendImpl &) = delete; + + int bind(const struct sockaddr *name, socklen_t addrlen); + int close(); + + /// Send a UDP packet to the specified destination. + ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + + int setsockopt(int level, int optname, const void *optval, socklen_t optlen); + int getsockopt(int level, int optname, void *optval, socklen_t *optlen); + + int setblocking(bool blocking); + + bool ready() const { return false; } + int get_fd() const { return -1; } + + protected: + /// Convert lwip ip_addr_t and port to sockaddr. + int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + + /// Shared close logic — removes the udp pcb. Caller must hold LWIP_LOCK. + int close_internal_locked_(); + + struct udp_pcb *pcb_; + sa_family_t family_; +}; + +/// UDP socket with receive support for LWIP raw API. +/// Extends LWIPRawUDPSendImpl with a fixed-size ring buffer for incoming packets. +/// Inheritance is private (base dtor is non-virtual; converting to a base +/// pointer would leak queued pbufs on destruction). +class LWIPRawUDPImpl : private LWIPRawUDPSendImpl { + public: + /// Caller (the factory) must hold the lwip lock; registers the recv callback. + LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb); + ~LWIPRawUDPImpl(); + + using LWIPRawUDPSendImpl::bind; + using LWIPRawUDPSendImpl::get_fd; + using LWIPRawUDPSendImpl::getsockopt; + using LWIPRawUDPSendImpl::sendto; + using LWIPRawUDPSendImpl::setblocking; + using LWIPRawUDPSendImpl::setsockopt; + + /// Close the socket, flushing any queued rx packets first. + int close(); + + /// Read the next queued packet, discarding source address info. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t read(void *buf, size_t len); + /// Read the next queued packet and return the source address. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); + + /// Returns true if there are packets available to read. + /// Intentionally unlocked — same rationale as LWIPRawImpl::ready(). + bool ready() const { return this->rx_count_ > 0; } + + /// Number of packets dropped because the rx queue was full (saturating). + uint16_t get_rx_dropped() const { return this->rx_dropped_; } + + protected: + static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); + + /// Ring buffer for received UDP packets. + /// Both producer (recv callback) and consumer (main loop) are serialized by the + /// lwip lock — the callback runs under lwip core lock, and consumer methods hold + /// LWIP_LOCK(). All 4 slots are usable (no wasted slot for full/empty distinction). + /// No heap allocation in the recv callback — packets are dropped if the queue is full. + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; + static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; + static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); + // Fields are written by recv_fn_ before rx_count_ makes a slot visible + struct UDPRxPacket { + ip_addr_t src_addr; + struct pbuf *pb; + uint16_t src_port; + }; + std::array rx_queue_{}; + uint16_t rx_dropped_{0}; + uint8_t rx_read_idx_{0}; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 7f3b706cd8..9cba164716 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,6 +84,9 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 89002cdd19..630d68b562 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,6 +8,7 @@ #include #include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" #ifdef USE_HOST #include "esphome/core/wake.h" #endif @@ -129,25 +130,7 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s return 0; } -std::unique_ptr socket_ip(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket(AF_INET6, type, protocol); -#else - return socket(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} - -#ifdef USE_SOCKET_IMPL_LWIP_TCP -// LWIP_TCP has separate Socket/ListenSocket types — needs out-of-line factory. -// BSD and LWIP_SOCKETS define this inline in socket.h. -std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_listen_loop_monitored(AF_INET6, type, protocol); -#else - return socket_listen_loop_monitored(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} -#endif +std::unique_ptr socket_ip(int type, int protocol) { return socket(IP_DOMAIN, type, protocol); } #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) // Callback must be a temporary lambda @@ -216,28 +199,41 @@ template static bool foreach_eligible_ipv6_if(F &&callback) { } return ctx.found_any; #else - bool found_any = false; - struct netif *netif; - NETIF_FOREACH(netif) { - if (netif->name[0] == 'l' && netif->name[1] == 'o') { - continue; + // The netif list needs the core lock, but the callback's setsockopt() takes that lock itself, so the + // indices are collected first. One that goes away before the callback runs is rejected by setsockopt(). + // Devices have only a few interfaces, so the cap drops nothing in practice. + static constexpr size_t MAX_IFS = 8; + StaticVector indices; + { + LwIPLock lock; + struct netif *netif; + NETIF_FOREACH(netif) { + if (indices.size() == MAX_IFS) { + break; + } + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + indices.push_back(netif_get_index(netif)); } - if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { - continue; - } - found_any = true; - if (callback(netif_get_index(netif))) { + } + for (uint8_t index : indices) { + if (callback(index)) { break; } } - if (!found_any) { + if (indices.empty()) { errno = EADDRNOTAVAIL; } - return found_any; + return !indices.empty(); #endif } +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS -bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) { +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out) { if (strchr(ip_address, ':') == nullptr) { struct in_addr ipv4mc {}; #ifdef USE_ZEPHYR @@ -267,7 +263,7 @@ bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_ind } return true; } -#if USE_NETWORK_IPV6 +#ifdef USE_SOCKET_IPV6 struct ipv6_mreq imreq6 {}; #ifdef USE_SOCKET_IMPL_BSD_SOCKETS #ifdef USE_ZEPHYR @@ -318,9 +314,10 @@ bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_ind #else errno = EINVAL; return false; -#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IPV6 } +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { #if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) @@ -337,6 +334,7 @@ bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { return true; }); if (ifindex == 0) { + errno = EADDRNOTAVAIL; return false; } } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index d1a70c5aa8..e8f9208774 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -20,6 +20,7 @@ #include "lwip_sockets_impl.h" #elif defined(USE_SOCKET_IMPL_LWIP_TCP) #include "lwip_raw_tcp_impl.h" +#include "lwip_raw_udp_impl.h" #endif namespace esphome::socket { @@ -27,17 +28,37 @@ namespace esphome::socket { // Type aliases — only one implementation is active per build. // Socket is the concrete type for connected sockets. // ListenSocket is the concrete type for listening/server sockets. -// On BSD and LWIP_SOCKETS, both aliases resolve to the same type. +// UDPSocket is the concrete type for UDP sockets (send + receive). +// UDPSendSocket is the concrete type for send-only UDP sockets. +// On BSD and LWIP_SOCKETS, all aliases resolve to the same type. // On LWIP_TCP, they are different types (no virtual dispatch between them). #ifdef USE_SOCKET_IMPL_BSD_SOCKETS using Socket = BSDSocketImpl; using ListenSocket = BSDSocketImpl; +using UDPSendSocket = BSDSocketImpl; +using UDPSocket = BSDSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS) using Socket = LwIPSocketImpl; using ListenSocket = LwIPSocketImpl; +using UDPSendSocket = LwIPSocketImpl; +using UDPSocket = LwIPSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_TCP) using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; +using UDPSendSocket = LWIPRawUDPSendImpl; +using UDPSocket = LWIPRawUDPImpl; +#endif + +// Domain used by the socket_ip_* helpers: newest available IP domain. +#if USE_NETWORK_IPV6 +inline constexpr int IP_DOMAIN = AF_INET6; +#else +inline constexpr int IP_DOMAIN = AF_INET; +#endif + +// IPv6 sockets with multicast membership; the raw lwIP implementation has no IPv6 membership support. +#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) +#define USE_SOCKET_IPV6 #endif #ifdef USE_LWIP_FAST_SELECT @@ -105,6 +126,36 @@ std::unique_ptr socket_ip(int type, int protocol); /// File descriptors >= FD_SETSIZE will not be monitored and will log an error. std::unique_ptr socket_loop_monitored(int domain, int type, int protocol); +/// Create a send-only UDP socket (socket_udp_send), a UDP socket with receive +/// support (socket_udp), or a UDP socket monitored for data in the main loop +/// (socket_udp_loop_monitored). +#ifdef USE_SOCKET_IMPL_LWIP_TCP +std::unique_ptr socket_udp_send(int domain, int protocol); +std::unique_ptr socket_udp(int domain, int protocol); +// Wake is built into the recv callback, so monitoring needs nothing extra. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_udp(domain, protocol); +} +#else +inline std::unique_ptr socket_udp_send(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +inline std::unique_ptr socket_udp(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +// Registers the socket with the Application's select() loop. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_loop_monitored(domain, SOCK_DGRAM, protocol); +} +#endif + +/// socket_udp* variants using the newest available IP domain. +inline std::unique_ptr socket_ip_udp_send(int protocol) { return socket_udp_send(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp(int protocol) { return socket_udp(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp_loop_monitored(int protocol) { + return socket_udp_loop_monitored(IP_DOMAIN, protocol); +} + /// Create a listening socket of the given domain, type and protocol. /// Create a listening socket and monitor it for data in the main loop. /// Create a listening socket in the newest available IP domain and monitor it. @@ -112,7 +163,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol // LWIP_TCP has separate Socket/ListenSocket types — needs distinct factory functions. std::unique_ptr socket_listen(int domain, int type, int protocol); std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); -std::unique_ptr socket_ip_loop_monitored(int type, int protocol); #else // BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate. inline std::unique_ptr socket_listen(int domain, int type, int protocol) { @@ -121,14 +171,10 @@ inline std::unique_ptr socket_listen(int domain, int type, int pro inline std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { return socket_loop_monitored(domain, type, protocol); } +#endif inline std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_loop_monitored(AF_INET6, type, protocol); -#else - return socket_loop_monitored(AF_INET, type, protocol); -#endif + return socket_listen_loop_monitored(IP_DOMAIN, type, protocol); } -#endif /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). /// @param addr Destination sockaddr structure @@ -150,16 +196,16 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) /// Join a multicast group on the given socket. /// @param sock The socket to join on -/// @param ip_address Null-terminated multicast address string (IPv4 or IPv6) +/// @param ip_address Null-terminated multicast address string (IPv4, or IPv6 except on LWIP_TCP) /// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For /// link-local IPv6 groups, the caller must also copy this into sin6_scope_id /// before bind() -- POSIX requires it, LwIP ignores it. /// @return true on success, false on failure (errno set) -bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 /// Configure the outgoing IPv6 multicast interface on the given socket. /// @param sock The socket to configure diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index 16df864082..0a0c4a59fb 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor +from esphome.components import binary_sensor, sensor, socket from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( @@ -35,16 +35,21 @@ CONFIG_BINARY_SENSORS_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(StatsdComponent), - cv.Required(CONF_HOST): cv.string_strict, - cv.Optional(CONF_PORT, default=8125): cv.port, - cv.Optional(CONF_PREFIX, default=""): cv.string_strict, - cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), - cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list(CONFIG_BINARY_SENSORS_SCHEMA), - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(StatsdComponent), + cv.Required(CONF_HOST): cv.string_strict, + cv.Optional(CONF_PORT, default=8125): cv.port, + cv.Optional(CONF_PREFIX, default=""): cv.string_strict, + cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), + cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list( + CONFIG_BINARY_SENSORS_SCHEMA + ), + } + ).extend(cv.polling_component_schema("10s")), + socket.consume_sockets(1, "statsd", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 12af8345f1..1c9474bdb7 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -13,28 +13,15 @@ static const uint16_t SEND_THRESHOLD = 1024; ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { -#ifndef USE_ESP8266 - this->sock_ = esphome::socket::socket(AF_INET, SOCK_DGRAM, 0); - - struct sockaddr_in source; - source.sin_family = AF_INET; - source.sin_addr.s_addr = htonl(INADDR_ANY); - source.sin_port = htons(this->port_); - this->sock_->bind((struct sockaddr *) &source, sizeof(source)); + this->sock_ = esphome::socket::socket_udp_send(AF_INET, 0); + if (!this->sock_) { + this->mark_failed(LOG_STR("Could not create socket")); + return; + } this->destination_.sin_family = AF_INET; this->destination_.sin_port = htons(this->port_); this->destination_.sin_addr.s_addr = inet_addr(this->host_); -#endif -} - -StatsdComponent::~StatsdComponent() { -#ifndef USE_ESP8266 - if (!this->sock_) { - return; - } - this->sock_->close(); -#endif } void StatsdComponent::dump_config() { @@ -141,19 +128,7 @@ void StatsdComponent::update() { } void StatsdComponent::send_(std::string *out) { - if (out->empty()) { - return; - } -#ifdef USE_ESP8266 - IPAddress ip; - ip.fromString(this->host_); - - this->sock_.beginPacket(ip, this->port_); - this->sock_.write((const uint8_t *) out->c_str(), out->length()); - this->sock_.endPacket(); - -#else - if (!this->sock_) { + if (out->empty() || !this->sock_) { return; } @@ -162,7 +137,6 @@ void StatsdComponent::send_(std::string *out) { if (n_bytes != static_cast(out->length())) { ESP_LOGE(TAG, "Failed to send UDP packed (%d of %d)", n_bytes, out->length()); } -#endif } } // namespace esphome::statsd diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 7cbde6d743..a12cc65810 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -20,17 +20,10 @@ #include "esphome/components/logger/logger.h" #endif -#ifdef USE_ESP8266 -#include "WiFiUdp.h" -#include "IPAddress.h" -#endif - namespace esphome::statsd { class StatsdComponent final : public PollingComponent { public: - ~StatsdComponent(); - void setup() override; void dump_config() override; void update() override; @@ -71,12 +64,8 @@ class StatsdComponent final : public PollingComponent { std::vector sensors_; -#ifdef USE_ESP8266 - WiFiUDP sock_; -#else - std::unique_ptr sock_; - struct sockaddr_in destination_; -#endif + std::unique_ptr sock_; + struct sockaddr_in destination_ {}; void send_(std::string *out); }; diff --git a/esphome/components/stcc4/stcc4.h b/esphome/components/stcc4/stcc4.h index ad25130ec7..1b8e02accc 100644 --- a/esphome/components/stcc4/stcc4.h +++ b/esphome/components/stcc4/stcc4.h @@ -12,14 +12,15 @@ enum class MeasurementMode : uint8_t { }; class STCC4Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void update() override; - void set_co2_sensor(sensor::Sensor *co2) { this->co2_sensor_ = co2; } - void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } - void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; } void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; } void set_ambient_pressure_compensation(float pressure_in_hpa); @@ -36,9 +37,6 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks); bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2); - sensor::Sensor *co2_sensor_{nullptr}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_source_{nullptr}; sensor::Sensor *humidity_source_{nullptr}; sensor::Sensor *ambient_pressure_source_{nullptr}; diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index c0a60cacbf..753401e6aa 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -1,13 +1,12 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_STATE from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component -CONF_SERIAL_NUMBER = "serial_number" - CONFIG_SCHEMA = cv.All( cv.Schema( { diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index e4aa0684c7..d3223484ff 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -43,7 +43,7 @@ _PUMP_SPEED_SCHEMA = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) -# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +# Sensors per model, keyed by config key MODEL_SENSORS = { CONF_SYSTASOLAR_AQUA: { CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, @@ -77,10 +77,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - for key in MODEL_SENSORS[config[CONF_MODEL]]: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + # The schema only lets a model's own keys through, so unconfigured ones are skipped here + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE_TSA, var.set_temperature_tsa_sensor) + await sensors(CONF_TEMPERATURE_TSE, var.set_temperature_tse_sensor) + await sensors(CONF_TEMPERATURE_TWU, var.set_temperature_twu_sensor) + await sensors(CONF_TEMPERATURE_TW2, var.set_temperature_tw2_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h index 3688176d00..1b135c9666 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.h +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -8,21 +8,15 @@ namespace esphome::systa_bus { class SystaSolarAquaSensor : public SystaBusListener, public Component { + SUB_SENSOR(temperature_tsa) + SUB_SENSOR(temperature_tse) + SUB_SENSOR(temperature_twu) + SUB_SENSOR(temperature_tw2) + SUB_SENSOR(pump_speed) + public: void dump_config() override; - void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } - void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } - void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } - void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } - void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } void handle_message(std::span message) override; - - protected: - sensor::Sensor *temperature_tsa_sensor_{nullptr}; - sensor::Sensor *temperature_tse_sensor_{nullptr}; - sensor::Sensor *temperature_twu_sensor_{nullptr}; - sensor::Sensor *temperature_tw2_sensor_{nullptr}; - sensor::Sensor *pump_speed_sensor_{nullptr}; }; } // namespace esphome::systa_bus diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py new file mode 100644 index 0000000000..67b38dc21e --- /dev/null +++ b/esphome/components/tca8418/__init__.py @@ -0,0 +1,156 @@ +import logging + +from esphome import automation +import esphome.codegen as cg +from esphome.components import gpio_expander, i2c, key_provider +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c"] +DOMAIN = "tca8418" +AUTO_LOAD = ["key_provider", "gpio_expander"] +MULTI_CONF = True + +# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs +_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") + + +async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: + _request_listener_slot(str(keypad)) + cg.add(keypad.register_listener(var)) + + +CONF_GPI_EVENTS = "gpi_events" +CONF_KEYPAD_ID = "keypad_id" + +# Matrix keys are numbered row by row from 1 with ten columns per row; pins outside +# the matrix report as individual inputs 97..114 (ROW0..7, then COL0..9) +MATRIX_COLUMNS = 10 +MATRIX_KEY_MAX = 80 +GPI_KEY_MIN = 97 +GPI_KEY_MAX = 114 + + +def _matrix_key(row: int, col: int) -> int: + return row * MATRIX_COLUMNS + col + 1 + + +tca8418_ns = cg.esphome_ns.namespace("tca8418") +TCA8418Component = tca8418_ns.class_( + "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice +) + + +def _ascii_string(value: str) -> str: + """A string of single byte characters, so it lines up with the matrix.""" + value = cv.string_strict(value) + if not value.isascii(): + raise cv.Invalid( + "Only ASCII characters can be used, since each key is one character" + ) + return value + + +def _validate(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS, 0) + columns = config.get(CONF_COLUMNS, 0) + if (keys := config.get(CONF_KEYS)) is not None: + if rows == 0: + raise cv.Invalid( + "'keys' maps the key matrix, so 'rows' and 'columns' are required", + path=[CONF_KEYS], + ) + if len(keys) != rows * columns: + raise cv.Invalid( + f"'keys' must have exactly {rows * columns} characters " + f"({rows} rows x {columns} columns), got {len(keys)}", + path=[CONF_KEYS], + ) + # Individual inputs report as 97..114, which are also the codes for the + # letters 'a' to 'r'. Both reach on_key and key collectors as a number. + if ( + keys is not None + and config[CONF_GPI_EVENTS] + and ( + clashing := sorted( + {k for k in keys if GPI_KEY_MIN <= ord(k) <= GPI_KEY_MAX} + ) + ) + ): + _LOGGER.warning( + "In '%s', the key(s) %s cannot be told apart from an individual input " + "on the '%s' and key collector paths, because both are reported as the " + "same number. Binary sensors are unaffected. Use other characters, or " + "set '%s: false', if this matters.", + CONF_KEYS, + ", ".join(f"'{k}'" for k in clashing), + CONF_ON_KEY, + CONF_GPI_EVENTS, + ) + if rows == 0 and not config[CONF_GPI_EVENTS]: + raise cv.Invalid( + "Nothing to report: set 'rows' and 'columns' for a key matrix, " + "or leave 'gpi_events' enabled to report individual inputs" + ) + return config + + +_MATRIX_MSG = ( + "'rows' and 'columns' must both be set to use the key matrix, " + "or both left unset to use every pin as an individual input" +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TCA8418Component), + # The key matrix occupies ROW0..ROW(rows-1) and COL0..COL(columns-1). + # Leave both unset to use every pin as an individual input instead. + cv.Inclusive(CONF_ROWS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=8 + ), + cv.Inclusive(CONF_COLUMNS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=10 + ), + cv.Optional(CONF_KEYS): _ascii_string, + # Report pins that are not part of the matrix as individual inputs. + cv.Optional(CONF_GPI_EVENTS, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + cv.Optional(CONF_ON_KEY): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x34)), + _validate, +) + + +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 (rows := config.get(CONF_ROWS)) is not None: + columns = config[CONF_COLUMNS] + cg.add(var.set_rows(rows)) + cg.add(var.set_columns(columns)) + if (keys := config.get(CONF_KEYS)) is not None: + # Indexed by key number - 1, with 0 where no key is assigned. + table = [0] * _matrix_key(rows - 1, columns - 1) + for index, char in enumerate(keys.encode()): + table[_matrix_key(*divmod(index, columns)) - 1] = char + array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) + cg.add(var.set_keys(array, len(table))) + cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) + if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + for conf in config.get(CONF_ON_KEY, []): + await automation.build_callback_automation( + var, "add_on_key_callback", [(cg.uint8, "x")], conf + ) diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py new file mode 100644 index 0000000000..3e1e766c57 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -0,0 +1,143 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_KEY, CONF_ROW +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import ( + CONF_GPI_EVENTS, + CONF_KEYPAD_ID, + GPI_KEY_MAX, + GPI_KEY_MIN, + MATRIX_COLUMNS, + MATRIX_KEY_MAX, + TCA8418Component, + _ascii_string, + _matrix_key, + register_listener, + tca8418_ns, +) + +DEPENDENCIES = ["tca8418"] + +CONF_KEY_CODE = "key_code" + +TCA8418BinarySensor = tca8418_ns.class_( + "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff +) + + +def _key_code(value: int) -> int: + value = cv.int_(value) + if 1 <= value <= MATRIX_KEY_MAX or GPI_KEY_MIN <= value <= GPI_KEY_MAX: + return value + raise cv.Invalid( + f"Key numbers run from 1 to {MATRIX_KEY_MAX} for the key matrix, " + f"and {GPI_KEY_MIN} to {GPI_KEY_MAX} for individual inputs" + ) + + +_POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + { + cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), + cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=7 + ), + cv.Inclusive(CONF_COL, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=9 + ), + cv.Optional(CONF_KEY): cv.All( + cv.string, + _ascii_string, + cv.Length(min=1, max=1, msg="Key must be a single character"), + ), + cv.Optional(CONF_KEY_CODE): _key_code, + } + ), + cv.has_exactly_one_key(CONF_ROW, CONF_KEY, CONF_KEY_CODE), +) + + +def _final_validate(config: ConfigType) -> None: + """Resolve the key to the number the device reports, checked against the keypad's matrix.""" + full_config = fv.full_config.get() + keypad_path = full_config.get_path_for_id(config[CONF_KEYPAD_ID])[:-1] + keypad = full_config.get_config_for_path(keypad_path) + rows = keypad.get(CONF_ROWS, 0) + columns = keypad.get(CONF_COLUMNS, 0) + + if (row := config.get(CONF_ROW)) is not None: + col = config[CONF_COL] + if row >= rows or col >= columns: + raise cv.Invalid( + f"Position {row}/{col} is outside the {rows} x {columns} key " + "matrix of the keypad it belongs to", + path=[CONF_ROW], + ) + config[CONF_KEY_CODE] = _matrix_key(row, col) + return + + if (key_code := config.get(CONF_KEY_CODE)) is not None: + if key_code <= MATRIX_KEY_MAX: + row, col = divmod(key_code - 1, MATRIX_COLUMNS) + if row >= rows or col >= columns: + raise cv.Invalid( + f"Key number {key_code} is position {row}/{col}, which is " + f"outside the {rows} x {columns} key matrix of the keypad it " + "belongs to", + path=[CONF_KEY_CODE], + ) + return + if not keypad[CONF_GPI_EVENTS]: + raise cv.Invalid( + f"Key number {key_code} is an individual input, but the keypad has " + f"'{CONF_GPI_EVENTS}' turned off", + path=[CONF_KEY_CODE], + ) + # 97..104 are ROW0..ROW7 and 105..114 are COL0..COL9. A pin used by the + # matrix cannot also report on its own. + offset = key_code - GPI_KEY_MIN + if offset < 8: + in_matrix, pin = offset < rows, f"ROW{offset}" + else: + in_matrix, pin = offset - 8 < columns, f"COL{offset - 8}" + if in_matrix: + raise cv.Invalid( + f"Key number {key_code} is {pin}, which the key matrix is using, so " + "it cannot report as an individual input", + path=[CONF_KEY_CODE], + ) + return + + if (key := config.get(CONF_KEY)) is not None: + keys = keypad.get(CONF_KEYS) + if keys is None: + raise cv.Invalid( + f"'{CONF_KEY}' names a key from the keypad's 'keys', which is not set", + path=[CONF_KEY], + ) + if (index := keys.find(key)) < 0: + raise cv.Invalid( + f"'{key}' is not one of the keypad's keys", path=[CONF_KEY] + ) + if keys.count(key) > 1: + raise cv.Invalid( + f"'{key}' appears more than once in the keypad's keys, so it does " + f"not name one key. Use '{CONF_ROW}' and '{CONF_COL}' instead", + path=[CONF_KEY], + ) + config[CONF_KEY_CODE] = _matrix_key(*divmod(index, columns)) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) + await register_listener(keypad, var) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h new file mode 100644 index 0000000000..d6d58b93bc --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../tca8418.h" + +namespace esphome::tca8418 { + +/// One key of a TCA8418, matched by the key number codegen resolved from the config +class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { + public: + explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} + + void key_pressed(uint8_t key) override { + if (key == this->key_) + this->publish_state(true); + } + void key_released(uint8_t key) override { + if (key == this->key_) + this->publish_state(false); + } + + protected: + const uint8_t key_; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp new file mode 100644 index 0000000000..7add67e9b0 --- /dev/null +++ b/esphome/components/tca8418/tca8418.cpp @@ -0,0 +1,170 @@ +#include "tca8418.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::tca8418 { + +ESPHOME_LOG_TAG(TAG, "tca8418"); + +// Minimum gap between event queue reads; the ten entry queue holds anything arriving sooner. +static constexpr uint32_t POLL_INTERVAL_MS = 50; + +void IRAM_ATTR TCA8418Component::interrupt_handler(TCA8418Component *arg) { arg->enable_loop_soon_any_context(); } + +void TCA8418Component::setup() { + // The configuration register reads back, so use it to check the device is there. + uint8_t config; + if (!this->read_byte(TCA8418_REG_CFG, &config)) { + ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); + this->mark_failed(); + return; + } + + if (!this->configure_pins_()) { + ESP_LOGE(TAG, "Failed to configure pins"); + this->mark_failed(); + return; + } + + // Report key events, and keep the interrupt asserted while events remain queued. + if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { + ESP_LOGE(TAG, "Failed to write the configuration register"); + this->mark_failed(); + return; + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // The interrupt output is active low, so a falling edge means an event was + // queued. The handler only wakes the loop; the queue is read from there. + this->interrupt_pin_->attach_interrupt(&TCA8418Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE); + } + + // Discard anything the device queued before it was configured, and clear + // every interrupt flag: leaving one set holds the interrupt output asserted. + if (!this->drain_events_(false)) { + ESP_LOGE(TAG, "Failed to clear the event queue"); + this->mark_failed(); + return; + } + + // With an interrupt pin there is nothing to do until it fires. Stay running + // if the device still has something queued, since that produces no new edge. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::configure_pins_() { + // Each of the three registers covers a group of pins: ROW0-7, COL0-7, COL8-9. + uint8_t matrix[3] = {0, 0, 0}; + if (this->rows_ > 0) { + matrix[0] = static_cast((1U << this->rows_) - 1U); + const uint8_t low_columns = this->columns_ > 8 ? 8 : this->columns_; + matrix[1] = static_cast((1U << low_columns) - 1U); + if (this->columns_ > 8) + matrix[2] = static_cast((1U << (this->columns_ - 8U)) - 1U); + } + + // Pins outside the matrix report individually when they are in event mode. + // The device ignores these bits for pins that belong to the matrix. + const uint8_t gpi[3] = { + static_cast(this->gpi_events_ ? ~matrix[0] : 0), + static_cast(this->gpi_events_ ? ~matrix[1] : 0), + static_cast(this->gpi_events_ ? (0x03 & ~matrix[2]) : 0), + }; + + for (uint8_t i = 0; i < 3; i++) { + // Enable the internal pull-ups (a 0 bit enables the pull-up) so that + // buttons can simply switch their pin to ground. + if (!this->write_byte(TCA8418_REG_GPIO_PULL1 + i, 0x00) || !this->write_byte(TCA8418_REG_KP_GPIO1 + i, matrix[i]) || + !this->write_byte(TCA8418_REG_GPI_EM1 + i, gpi[i]) || !this->write_byte(TCA8418_REG_GPIO_INT_EN1 + i, gpi[i])) { + return false; + } + } + return true; +} + +void TCA8418Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + + if (!this->drain_events_(true)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::drain_events_(bool dispatch) { + uint8_t count; + if (!this->read_byte(TCA8418_REG_KEY_LCK_EC, &count)) + return false; + count &= TCA8418_EVENT_COUNT_MASK; + // An idle poll has no flags to clear, unless INT is still asserted from a failed clear + if (count == 0 && dispatch && (this->interrupt_pin_ == nullptr || this->interrupt_pin_->digital_read())) + return true; + // The overflow flag stays clear with a full queue, so check the count + if (dispatch && count >= TCA8418_FIFO_DEPTH) { + ESP_LOGW(TAG, "Event queue is full - some key presses may have been lost"); + } + + uint8_t event; + for (uint8_t i = 0; i < count; i++) { + if (!this->read_byte(TCA8418_REG_KEY_EVENT_A, &event)) + return false; + if (dispatch) + this->dispatch_(event & TCA8418_KEY_CODE_MASK, (event & TCA8418_KEY_PRESSED) != 0); + } + return this->write_byte(TCA8418_REG_INT_STAT, TCA8418_INT_STAT_ALL); +} + +uint8_t TCA8418Component::key_char_(uint8_t key) const { + if (key == 0 || key > this->keys_size_) + return 0; + return progmem_read_byte(&this->keys_[key - 1]); +} + +void TCA8418Component::dispatch_(uint8_t key, bool pressed) { + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + +#ifdef TCA8418_LISTENER_COUNT + for (auto *listener : this->listeners_) { + if (pressed) { + listener->key_pressed(key); + } else { + listener->key_released(key); + } + } +#endif + + if (!pressed) + return; + + // Key collectors and automations receive the mapped character when a key map + // is configured, and the key number otherwise. + const uint8_t key_char = this->key_char_(key); + this->send_key_(key_char != 0 ? key_char : key); +} + +void TCA8418Component::dump_config() { + ESP_LOGCONFIG(TAG, + "TCA8418:\n" + " Individual inputs: %s", + YESNO(this->gpi_events_)); + if (this->rows_ > 0) { + ESP_LOGCONFIG(TAG, " Key matrix: %u rows x %u columns", this->rows_, this->columns_); + } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h new file mode 100644 index 0000000000..383182c1e0 --- /dev/null +++ b/esphome/components/tca8418/tca8418.h @@ -0,0 +1,85 @@ +#pragma once + +#include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tca8418 { + +// Register map (see TI datasheet SCPS231) +static constexpr uint8_t TCA8418_REG_CFG = 0x01; +static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02; +static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03; +static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; // top of the event FIFO +static constexpr uint8_t TCA8418_REG_GPIO_INT_EN1 = 0x1A; +static constexpr uint8_t TCA8418_REG_KP_GPIO1 = 0x1D; +static constexpr uint8_t TCA8418_REG_GPI_EM1 = 0x20; +static constexpr uint8_t TCA8418_REG_GPIO_PULL1 = 0x2C; + +// CFG +static constexpr uint8_t TCA8418_CFG_KEY_INT_EN = 0x01; // interrupt on key events +static constexpr uint8_t TCA8418_CFG_INT_CFG = 0x10; // re-assert INT while events remain +// INT_STAT +static constexpr uint8_t TCA8418_INT_STAT_ALL = 0x1F; // every interrupt flag +// KEY_LCK_EC: the low nibble is the number of queued events +static constexpr uint8_t TCA8418_EVENT_COUNT_MASK = 0x0F; +// Key events: bit 7 set = pressed, clear = released; bits 6-0 are the key number +static constexpr uint8_t TCA8418_KEY_PRESSED = 0x80; +static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; +// The event queue holds ten entries, which bounds how many can be read at once. +static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; + +class TCA8418Listener { + public: + /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). + virtual void key_pressed(uint8_t key) {} + virtual void key_released(uint8_t key) {} +}; + +class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_rows(uint8_t rows) { this->rows_ = rows; } + void set_columns(uint8_t columns) { this->columns_ = columns; } + void set_gpi_events(bool gpi_events) { this->gpi_events_ = gpi_events; } + void set_keys(const uint8_t *keys, uint8_t size) { + this->keys_ = keys; + this->keys_size_ = size; + } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + +#ifdef TCA8418_LISTENER_COUNT + void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#endif + + /// Wakes the loop so queued events are read. Runs in interrupt context. + static void IRAM_ATTR interrupt_handler(TCA8418Component *arg); + + protected: + /// Write the matrix / individual-input pin configuration. Returns false on I2C failure. + bool configure_pins_(); + /// Read every queued key event, dispatching them if asked, then clear the interrupt flags. + /// Returns false on I2C failure. + bool drain_events_(bool dispatch); + void dispatch_(uint8_t key, bool pressed); + /// The character mapped to a matrix key, or 0 when it has none. + uint8_t key_char_(uint8_t key) const; + + const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 + InternalGPIOPin *interrupt_pin_{nullptr}; + uint32_t last_poll_{0}; +#ifdef TCA8418_LISTENER_COUNT + StaticVector listeners_; +#endif + uint8_t rows_{0}; + uint8_t columns_{0}; + uint8_t keys_size_{0}; + bool gpi_events_{true}; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index fbbc09bd2d..243185b674 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_MODE, CONF_MQTT_ID, CONF_ON_VALUE, - CONF_TRIGGER_ID, CONF_VALUE, CONF_WEB_SERVER, ) @@ -30,11 +29,6 @@ text_ns = cg.esphome_ns.namespace("text") Text = text_ns.class_("Text", cg.EntityBase) TextPtr = Text.operator("ptr") -# Triggers -TextStateTrigger = text_ns.class_( - "TextStateTrigger", automation.Trigger.template(cg.std_string) -) - # Conditions TextMode = text_ns.enum("TextMode") @@ -50,11 +44,7 @@ _TEXT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextComponent), cv.GenerateID(): cv.declare_id(Text), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TextStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), cv.Required(CONF_MODE): cv.enum(TEXT_MODES, upper=True), } ) @@ -104,8 +94,9 @@ async def setup_text_core_( cg.add(var.traits.set_mode(config[CONF_MODE])) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(cg.std_string, "x")], conf + ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h deleted file mode 100644 index bd6fb3cd23..0000000000 --- a/esphome/components/text/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "text.h" - -namespace esphome::text { - -class TextStateTrigger final : public Trigger { - public: - explicit TextStateTrigger(Text *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace esphome::text diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 959ce77c7a..2ff0421f39 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -59,6 +59,7 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) PrependFilter = text_sensor_ns.class_("PrependFilter", Filter) SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter) MapFilter = text_sensor_ns.class_("MapFilter", Filter) +Substitution = text_sensor_ns.struct("Substitution") @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -102,34 +103,29 @@ def validate_mapping(value): )(value) -@FILTER_REGISTRY.register( - "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) -) -async def substitute_filter_to_code(config, filter_id): - substitutions = [ +def _substitution_table(config: list[ConfigType]) -> MockObj: + """Shared PROGMEM Substitution table, ended by an empty pair so filters store no count.""" + pairs = [ cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), + Substitution, ("from", conf[CONF_FROM]), ("to", conf[CONF_TO]) ) for conf in config ] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(substitutions)), substitutions + end = cg.RawExpression("nullptr") + pairs.append(cg.StructInitializer(Substitution, ("from", end), ("to", end))) + return cg.shared_progmem_array( + "text_sensor_substitutions", Substitution, cg.ArrayInitializer(*pairs) ) +@FILTER_REGISTRY.register( + "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) +) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) -async def map_filter_to_code(config, filter_id): - mappings = [ - cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), - ) - for conf in config - ] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) +async def substitution_filter_to_code( + config: list[ConfigType], filter_id: ID +) -> MockObj: + return cg.new_Pvariable(filter_id, _substitution_table(config)) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 9d18fb9b1a..54457d8238 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -73,25 +73,25 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute — non-template helper -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - const size_t from_len = strlen(substitutions[i].from); - const size_t to_len = strlen(substitutions[i].to); +// Substitute +bool SubstituteFilter::new_value(std::string &value) { + for (const Substitution *s = this->substitutions_; s->from != nullptr; s++) { + const size_t from_len = strlen(s->from); + const size_t to_len = strlen(s->to); std::size_t pos = 0; - while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, substitutions[i].to, to_len); + while ((pos = value.find(s->from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, s->to, to_len); pos += to_len; } } return true; } -// Map — non-template helper -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - if (value == mappings[i].from) { - value.assign(mappings[i].to); +// Map +bool MapFilter::new_value(std::string &value) { + for (const Substitution *m = this->mappings_; m->from != nullptr; m++) { + if (value == m->from) { + value.assign(m->to); return true; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 6db76dcb64..7898d46686 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,8 +3,6 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER -#include - #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -123,25 +121,18 @@ struct Substitution { const char *to; }; -/// Non-template helper (implementation in filter.cpp) -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); - /// A simple filter that replaces a substring with another substring. -/// N is set by code generation to match the exact number of substitutions configured in YAML. -template class SubstituteFilter : public Filter { +/// `substitutions` is a codegen table in flash, shared by filters with the same list and +/// ended by an entry whose `from` is nullptr, so no count is stored. +class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions) { - init_array_from(this->substitutions_, substitutions); - } - bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } + explicit SubstituteFilter(const Substitution *substitutions) : substitutions_(substitutions) {} + bool new_value(std::string &value) override; protected: - std::array substitutions_{}; + const Substitution *substitutions_; }; -/// Non-template helper (implementation in filter.cpp) -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); - /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -166,17 +157,16 @@ bool map_filter_apply(const Substitution *mappings, size_t count, std::string &v * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 * - * N is set by code generation to match the exact number of mappings configured in YAML. + * `mappings` is a codegen table in flash, shared by filters with the same list and ended by + * an entry whose `from` is nullptr, so no count is stored. */ -template class MapFilter : public Filter { +class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings) { - init_array_from(this->mappings_, mappings); - } - bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } + explicit MapFilter(const Substitution *mappings) : mappings_(mappings) {} + bool new_value(std::string &value) override; protected: - std::array mappings_{}; + const Substitution *mappings_; }; } // namespace esphome::text_sensor diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 586b69d603..92a8e439d2 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -34,7 +34,7 @@ CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # esp_tinyusb's driver install (descriptors_set fails with no class and no # user-provided full_speed_config), which trips the task watchdog before # loop() ever runs. -_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) +_USB_CLASS_COMPONENTS = ("usb_cdc_acm", "tinyusb_keyboard") tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 14291fb328..ba2fbded92 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,8 @@ void TinyUSB::setup() { } // Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates. + // Setters write into tusb_cfg_ before setup(), so anything they set has to survive the reset. + const uint8_t *full_speed_config = this->tusb_cfg_.descriptor.full_speed_config; this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; @@ -39,6 +41,7 @@ void TinyUSB::setup() { .device = &this->usb_descriptor_, .string = this->string_descriptor_, .string_count = SIZE, + .full_speed_config = full_speed_config, }; // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 030356bc03..e9f7283b01 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -49,6 +49,8 @@ class TinyUSB final : public Component { void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } + /// Configuration descriptor from a class component that esp_tinyusb has no built-in default for (HID). + void set_full_speed_config(const uint8_t *descriptor) { this->tusb_cfg_.descriptor.full_speed_config = descriptor; } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english diff --git a/esphome/components/tinyusb_keyboard/__init__.py b/esphome/components/tinyusb_keyboard/__init__.py new file mode 100644 index 0000000000..18b49a95bd --- /dev/null +++ b/esphome/components/tinyusb_keyboard/__init__.py @@ -0,0 +1,110 @@ +import string + +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, +) +from esphome.components.tinyusb import TinyUSB +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_KEY +from esphome.types import ConfigType + +CODEOWNERS = ["@frederikbolding"] +# The keyboard supplies the whole configuration descriptor, with no room for the CDC interfaces +CONFLICTS_WITH = ["usb_cdc_acm"] +DEPENDENCIES = ["tinyusb"] +DOMAIN = "tinyusb_keyboard" + +CONF_MODIFIERS = "modifiers" +CONF_TINYUSB_ID = "tinyusb_id" +CONF_USAGE = "usage" + +# Keyboard page usage ids run without gaps from HID_KEY_A (0x04) through a..z, 1..9 and 0 +HID_KEY_A = 0x04 +KEY_CODES = { + char: HID_KEY_A + index + for index, char in enumerate(string.ascii_lowercase + "1234567890") +} + +tinyusb_keyboard_ns = cg.esphome_ns.namespace("tinyusb_keyboard") +TinyUSBKeyboard = tinyusb_keyboard_ns.class_("TinyUSBKeyboard", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TinyUSBKeyboard), + cv.GenerateID(CONF_TINYUSB_ID): cv.use_id(TinyUSB), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), +) + +_KEY_CODE = cv.Any(cv.enum(KEY_CODES), cv.int_range(min=10, max=255)) + + +def key_code(value: object) -> int: + """A single lowercase letter or digit, or a keyboard usage id. + + Usage ids 0 to 9 are never typed keys (error codes and the letters a to f), so an unquoted + digit is read as that digit. + """ + if isinstance(value, int) and 0 <= value <= 9: + value = str(value) + return _KEY_CODE(value) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + tinyusb = await cg.get_variable(config[CONF_TINYUSB_ID]) + cg.add(tinyusb.set_full_speed_config(tinyusb_keyboard_ns.CONFIGURATION_DESCRIPTOR)) + add_idf_sdkconfig_option("CONFIG_TINYUSB_HID_COUNT", 1) + + +KEYBOARD_ID_SCHEMA = {cv.GenerateID(): cv.use_id(TinyUSBKeyboard)} + +automation.register_apply_action( + "tinyusb_keyboard.press", + automation.maybe_simple_id( + { + **KEYBOARD_ID_SCHEMA, + cv.Required(CONF_KEY): cv.templatable(key_code), + cv.Optional(CONF_MODIFIERS, default=0): cv.templatable(cv.uint8_t), + } + ), + automation.ApplyCall( + "press_key({}, {})", ((CONF_KEY, cg.uint8), (CONF_MODIFIERS, cg.uint8)) + ), +) +automation.register_apply_action( + "tinyusb_keyboard.release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_keys()"), +) +automation.register_apply_action( + "tinyusb_keyboard.media_press", + automation.maybe_simple_id( + {**KEYBOARD_ID_SCHEMA, cv.Required(CONF_USAGE): cv.templatable(cv.uint16_t)} + ), + automation.ApplyCall("press_media({})", ((CONF_USAGE, cg.uint16),)), +) +automation.register_apply_action( + "tinyusb_keyboard.media_release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_media()"), +) diff --git a/esphome/components/tinyusb_keyboard/keyboard.cpp b/esphome/components/tinyusb_keyboard/keyboard.cpp new file mode 100644 index 0000000000..6d2be7f7eb --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.cpp @@ -0,0 +1,90 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "keyboard.h" +#include "esphome/core/log.h" + +namespace esphome::tinyusb_keyboard { + +ESPHOME_LOG_TAG(TAG, "tinyusb_keyboard"); + +void TinyUSBKeyboard::dump_config() { ESP_LOGCONFIG(TAG, "TinyUSB Keyboard"); } + +void TinyUSBKeyboard::set_keyboard_(uint8_t keycode, uint8_t modifiers) { + this->keycode_ = keycode; + this->modifiers_ = modifiers; + this->keyboard_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::set_consumer_(uint16_t usage) { + this->usage_ = usage; + this->consumer_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::flush_() { + if (!tud_mounted()) { + // No host is listening, and a key held from before it attached would be wrong, so drop the state + ESP_LOGV(TAG, "No host, dropping report"); + this->keyboard_pending_ = false; + this->consumer_pending_ = false; + this->wakeup_requested_ = false; + this->disable_loop(); + return; + } + if (tud_suspended()) { + // A key press wakes a sleeping host, like a real keyboard; the report goes out once the bus resumes. + // Signal once per suspend: a host that did not arm remote wakeup will not answer, and one that did + // must not be signalled on every pass. + if (!this->wakeup_requested_) { + this->wakeup_requested_ = true; + if (!tud_remote_wakeup()) { + ESP_LOGV(TAG, "Host did not allow remote wakeup"); + } + } + this->enable_loop(); + return; + } + this->wakeup_requested_ = false; + // tud_hid_*_report() returns false while the endpoint still holds the previous report, which is + // what happens for a press and a release in one action list. Keep retrying so the release gets out. + if (this->keyboard_pending_) { + uint8_t keycodes[6] = {this->keycode_}; + if (tud_hid_keyboard_report(REPORT_ID_KEYBOARD, this->modifiers_, keycodes)) { + this->keyboard_pending_ = false; + } + } + if (this->consumer_pending_ && !this->keyboard_pending_ && + tud_hid_report(REPORT_ID_CONSUMER, &this->usage_, sizeof(this->usage_))) { + this->consumer_pending_ = false; + } + if (this->keyboard_pending_ || this->consumer_pending_) { + this->enable_loop(); + } else { + this->disable_loop(); + } +} + +void TinyUSBKeyboard::loop() { this->flush_(); } + +} // namespace esphome::tinyusb_keyboard + +// TinyUSB resolves these at link time when HID is enabled +extern "C" { +const uint8_t *tud_hid_descriptor_report_cb(uint8_t /*instance*/) { + return esphome::tinyusb_keyboard::HID_REPORT_DESCRIPTOR; +} + +uint16_t tud_hid_get_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + uint8_t * /*buffer*/, uint16_t /*reqlen*/) { + return 0; +} + +// Keyboard LED state from the host; nothing consumes it yet +void tud_hid_set_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + const uint8_t * /*buffer*/, uint16_t /*bufsize*/) {} +} + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb_keyboard/keyboard.h b/esphome/components/tinyusb_keyboard/keyboard.h new file mode 100644 index 0000000000..4c86ee599c --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.h @@ -0,0 +1,66 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "esphome/core/component.h" + +#include "tusb.h" +// tusb.h only pulls the HID class in when CFG_TUD_HID is set, which the static analysis build does not do +#include "class/hid/hid_device.h" + +namespace esphome::tinyusb_keyboard { + +static constexpr uint8_t REPORT_ID_KEYBOARD = 1; +static constexpr uint8_t REPORT_ID_CONSUMER = 2; +static constexpr uint8_t ENDPOINT_IN = 0x81; +static constexpr uint8_t POLL_INTERVAL_MS = 10; + +// One HID interface carrying a keyboard and a consumer control (media keys), told apart by report id. +// Report ids rule out the boot protocol, so the interface declares no boot protocol, as TinyUSB's +// composite example does. +inline constexpr uint8_t HID_REPORT_DESCRIPTOR[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)), +}; + +// esp_tinyusb ships default configuration descriptors for CDC, MSC and NCM only, so the keyboard supplies its own +inline constexpr uint8_t CONFIGURATION_DESCRIPTOR[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(HID_REPORT_DESCRIPTOR), ENDPOINT_IN, CFG_TUD_HID_EP_BUFSIZE, + POLL_INTERVAL_MS), +}; + +class TinyUSBKeyboard final : public Component { + public: + void setup() override { this->disable_loop(); } + void loop() override; + void dump_config() override; + + /// Press one key (HID usage id) with the given modifier bitmask; every other key is reported released. + void press_key(uint8_t keycode, uint8_t modifiers) { this->set_keyboard_(keycode, modifiers); } + void release_keys() { this->set_keyboard_(0, 0); } + /// Press one consumer control usage, for example 0xE9 for volume up. + void press_media(uint16_t usage) { this->set_consumer_(usage); } + void release_media() { this->set_consumer_(0); } + + protected: + void set_keyboard_(uint8_t keycode, uint8_t modifiers); + void set_consumer_(uint16_t usage); + /// Sends whatever changed; a report the endpoint cannot take yet is retried from loop() + void flush_(); + + // The wanted state of each report, resent until the endpoint accepts it so a release is never lost. + // Only the latest state is kept, so consecutive key actions need a short delay between them. + uint16_t usage_{0}; + uint8_t keycode_{0}; + uint8_t modifiers_{0}; + bool keyboard_pending_{false}; + bool consumer_pending_{false}; + bool wakeup_requested_{false}; +}; + +} // namespace esphome::tinyusb_keyboard + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py new file mode 100644 index 0000000000..b3822f7582 --- /dev/null +++ b/esphome/components/tmc2209/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import tmc22xx + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc2209" + +tmc2209_ns = cg.esphome_ns.namespace("tmc2209") +TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) + +tmc22xx.register_actions("tmc2209", TMC2209Stepper) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py new file mode 100644 index 0000000000..5ef91dc591 --- /dev/null +++ b/esphome/components/tmc2209/stepper.py @@ -0,0 +1,14 @@ +from esphome.components import tmc22xx +from esphome.types import ConfigType + +from . import TMC2209Stepper + +AUTO_LOAD = ["tmc22xx"] +DEPENDENCIES = ["uart"] + +CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate + + +async def to_code(config: ConfigType) -> None: + await tmc22xx.new_tmc22xx(config) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp new file mode 100644 index 0000000000..040b362a8d --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -0,0 +1,13 @@ +#include "tmc2209.h" +#include "esphome/core/log.h" + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209"); + +void TMC2209Stepper::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209:"); + TMC22XXStepper::dump_config(); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h new file mode 100644 index 0000000000..2f880251e5 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/tmc22xx/tmc22xx.h" + +namespace esphome::tmc2209 { + +class TMC2209Stepper : public tmc22xx::TMC22XXStepper { + public: + void dump_config() override; + + protected: + uint8_t expected_version() const override { return 0x21; } +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py new file mode 100644 index 0000000000..ad7221f0b2 --- /dev/null +++ b/esphome/components/tmc22xx/__init__.py @@ -0,0 +1,211 @@ +"""Shared code for Trinamic stepper drivers configured over the single wire UART interface.""" + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import stepper, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DIR_PIN, + CONF_ID, + CONF_STEP_PIN, + CONF_UART_ID, +) +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc22xx" +DEPENDENCIES = ["uart"] + +CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" +CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" +CONF_ENN_PIN = "enn_pin" +CONF_HOLD_CURRENT = "hold_current" +CONF_IHOLD = "ihold" +CONF_IHOLDDELAY = "iholddelay" +CONF_INDEX_PIN = "index_pin" +CONF_INTERPOLATION = "interpolation" +CONF_INVERSE_DIRECTION = "inverse_direction" +CONF_IRUN = "irun" +CONF_MICROSTEPS = "microsteps" +CONF_OTTRIM = "ottrim" +CONF_RSENSE = "rsense" +CONF_RUN_CURRENT = "run_current" +CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TPOWERDOWN = "tpowerdown" +CONF_TPWM_THRESHOLD = "tpwm_threshold" +CONF_VSENSE = "vsense" + +tmc22xx_ns = cg.esphome_ns.namespace("tmc22xx") +TMC22XXStepper = tmc22xx_ns.class_( + "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice +) +StandstillMode = tmc22xx_ns.enum("StandstillMode") + +STANDSTILL_MODES = { + "normal": StandstillMode.STANDSTILL_MODE_NORMAL, + "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, + "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, + "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, +} + +validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) +validate_current = cv.All(cv.current, cv.positive_float) + + +def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: + """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" + return cv.All( + stepper.STEPPER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(cls), + cv.Optional(CONF_ADDRESS, default=0): cv.int_range(0, max_address), + cv.Optional(CONF_ENN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( + cv.frequency, cv.int_range(min=1) + ), + cv.Optional(CONF_RSENSE): cv.All( + cv.resistance, cv.float_range(min=0.0, min_included=False) + ), + cv.Optional(CONF_VSENSE): cv.boolean, + cv.Optional(CONF_OTTRIM): cv.int_range(0, 3), + cv.Optional(CONF_ANALOG_CURRENT_SCALE, default=False): cv.boolean, + cv.Optional(CONF_RUN_CURRENT): validate_current, + cv.Optional(CONF_HOLD_CURRENT): validate_current, + cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + cv.has_exactly_one_key(CONF_INDEX_PIN, CONF_STEP_PIN), + cv.has_none_or_all_keys(CONF_STEP_PIN, CONF_DIR_PIN), + _validate_analog_current_scale, + ) + + +def _validate_analog_current_scale(config: ConfigType) -> ConfigType: + if config[CONF_ANALOG_CURRENT_SCALE]: + for key in (CONF_RUN_CURRENT, CONF_HOLD_CURRENT): + if key in config: + raise cv.Invalid( + f"'{key}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', the " + f"current then depends on VREF; set '{CONF_IRUN}'/'{CONF_IHOLD}' " + "with the currents action instead", + path=[key], + ) + return config + + +def final_validate(config: ConfigType) -> ConfigType: + """Reject two drivers of the family with the same address on one UART bus.""" + for other in fv.full_config.get().get("stepper", []): + if other is config: + break + if ( + other.get(CONF_UART_ID) == config[CONF_UART_ID] + and other.get(CONF_ADDRESS) == config[CONF_ADDRESS] + ): + raise cv.Invalid( + f"Address {config[CONF_ADDRESS]} is already used by '{other[CONF_ID]}' " + "on the same UART bus" + ) + return config + + +async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + await stepper.register_stepper(var, config) + + cg.add(var.set_address(config[CONF_ADDRESS])) + cg.add(var.set_clock_frequency(config[CONF_CLOCK_FREQUENCY])) + cg.add(var.set_analog_current_scale(config[CONF_ANALOG_CURRENT_SCALE])) + cg.add(var.set_initial_microsteps(config[CONF_MICROSTEPS])) + for key, setter in ( + (CONF_ENN_PIN, var.set_enn_pin), + (CONF_STEP_PIN, var.set_step_pin), + (CONF_DIR_PIN, var.set_dir_pin), + (CONF_INDEX_PIN, var.set_index_pin), + ): + if (pin := config.get(key)) is not None: + cg.add(setter(await cg.gpio_pin_expression(pin))) + for key, setter in ( + (CONF_RSENSE, var.set_rsense), + (CONF_VSENSE, var.set_vsense), + (CONF_OTTRIM, var.set_ottrim), + (CONF_RUN_CURRENT, var.set_initial_run_current), + (CONF_HOLD_CURRENT, var.set_initial_hold_current), + ): + if (value := config.get(key)) is not None: + cg.add(setter(value)) + return var + + +def register_actions(domain: str, cls: MockObjClass) -> None: + """Register the actions shared by the family under `domain`, e.g. `tmc2209.enable`.""" + id_schema = {cv.GenerateID(): cv.use_id(cls)} + for name, enabled in (("enable", "true"), ("disable", "false")): + automation.register_apply_action( + f"{domain}.{name}", + automation.maybe_simple_id(id_schema), + automation.ApplyCall(f"set_enabled({enabled})"), + ) + + automation.register_apply_action( + f"{domain}.configure", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_INVERSE_DIRECTION): cv.templatable(cv.boolean), + cv.Optional(CONF_MICROSTEPS): cv.templatable(validate_microsteps), + cv.Optional(CONF_INTERPOLATION): cv.templatable(cv.boolean), + cv.Optional(CONF_ENABLE_SPREADCYCLE): cv.templatable(cv.boolean), + cv.Optional(CONF_TPWM_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField( + CONF_INVERSE_DIRECTION, "set_inverse_direction", cg.bool_ + ), + automation.ApplyField(CONF_MICROSTEPS, "set_microsteps", cg.uint16), + automation.ApplyField(CONF_INTERPOLATION, "set_interpolation", cg.bool_), + automation.ApplyField(CONF_ENABLE_SPREADCYCLE, "set_spreadcycle", cg.bool_), + automation.ApplyField(CONF_TPWM_THRESHOLD, "set_tpwm_threshold", cg.uint32), + ) + + automation.register_apply_action( + f"{domain}.currents", + cv.Schema( + { + **id_schema, + cv.Exclusive(CONF_RUN_CURRENT, "run"): cv.templatable(validate_current), + cv.Exclusive(CONF_IRUN, "run"): cv.templatable(cv.int_range(0, 31)), + cv.Exclusive(CONF_HOLD_CURRENT, "hold"): cv.templatable( + validate_current + ), + cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), + cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( + cv.enum(STANDSTILL_MODES, lower=True) + ), + } + ), + automation.ApplyField(CONF_RUN_CURRENT, "set_run_current", cg.float_), + automation.ApplyField(CONF_IRUN, "set_irun", cg.uint8), + automation.ApplyField(CONF_HOLD_CURRENT, "set_hold_current", cg.float_), + automation.ApplyField(CONF_IHOLD, "set_ihold", cg.uint8), + automation.ApplyField(CONF_IHOLDDELAY, "set_iholddelay", cg.uint8), + automation.ApplyField(CONF_TPOWERDOWN, "set_tpowerdown", cg.uint8), + automation.ApplyField( + CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode + ), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp new file mode 100644 index 0000000000..1628572d40 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -0,0 +1,372 @@ +#include "tmc22xx.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::tmc22xx { + +ESPHOME_LOG_TAG(TAG, "tmc22xx"); + +static constexpr uint8_t SYNC_BYTE = 0x05; +static constexpr uint8_t MASTER_ADDRESS = 0xFF; +static constexpr uint8_t WRITE_BIT = 0x80; +static constexpr size_t READ_REQUEST_SIZE = 4; +static constexpr size_t DATAGRAM_SIZE = 8; +static constexpr uint8_t DEFAULT_TOFF = 3; +static constexpr float SENSE_RESISTOR_OFFSET = 0.02f; // Ohm, added to RSENSE in the datasheet current formula +static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resistance when using internal sensing +// INDEX pulses are counted once per loop, so near the target the speed is limited to cover the remaining +// distance in no less than this time. A slow loop pass then cannot run far past the target. +static constexpr float APPROACH_TIME_S = 0.02f; +// Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer +static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; + +// CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first +static uint8_t datagram_crc(const uint8_t *data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; i++) { + const uint8_t reflected = reverse_bits(data[i]); + crc = crc8(&reflected, 1, crc, 0x07, true); + } + return crc; +} + +void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } + +void TMC22XXStepper::setup() { + if (this->enn_pin_ != nullptr) { + this->enn_pin_->setup(); + this->enn_pin_->digital_write(true); + } + + auto version = this->read_field(VERSION); + if (!version.has_value()) { + ESP_LOGE(TAG, "No response from driver at address %u", this->address_); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->version_ = *version; + if (this->version_ != this->expected_version()) { + ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); + } + + // Bring write-only registers in line with their cached values and clear the reset flag + this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); + this->write_register(REG_TPOWERDOWN, this->tpowerdown_); + this->write_register(REG_TPWMTHRS, this->tpwmthrs_); + this->write_register(REG_VACTUAL, 0); + this->write_register(REG_GSTAT, 0x07); + + this->write_field(PDN_DISABLE, true); + this->write_field(MSTEP_REG_SELECT, true); + this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); + this->write_field(I_SCALE_ANALOG, this->analog_current_scale_); + if (this->vsense_.has_value()) + this->write_field(VSENSE, *this->vsense_); + if (this->ottrim_.has_value()) + this->write_field(OTTRIM, *this->ottrim_); + this->vsense_active_ = this->read_field(VSENSE).value_or(0); + + // TOFF=0 disables the outputs, keep the current value so it can be restored on enable + auto toff = this->read_field(TOFF); + if (toff.has_value() && *toff != 0) + this->toff_ = *toff; + + this->set_microsteps(this->initial_microsteps_); + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + // INDEX toggles on every step of the internal pulse generator that VACTUAL drives + this->write_field(INDEX_OTPW, false); + this->write_field(INDEX_STEP, true); + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + // Step on both edges, so every step pin toggle is one step + this->write_field(DEDGE, true); + this->write_field(MULTISTEP_FILT, false); + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + this->set_enabled(true); +} + +void TMC22XXStepper::dump_config() { + ESP_LOGCONFIG(TAG, + " Address: %u\n" + " IC version: 0x%02X", + this->address_, this->version_); + // Register reads on a failed driver would only wait for the UART timeout + if (!this->is_failed()) { + uint8_t irun = this->read_field(IRUN).value_or(0); + uint8_t ihold = this->read_field(IHOLD).value_or(0); + ESP_LOGCONFIG(TAG, " Microsteps: %u", this->get_microsteps()); + if (this->analog_current_scale_) { + ESP_LOGCONFIG(TAG, + " Run current scale: %u\n" + " Hold current scale: %u\n" + " Current scaled by VREF", + irun, ihold); + } else { + ESP_LOGCONFIG(TAG, + " Run current: %.2f A\n" + " Hold current: %.2f A\n" + " Max current: %.2f A", + this->scale_to_current(irun), this->scale_to_current(ihold), this->scale_to_current(31)); + } + } + LOG_PIN(" ENN Pin: ", this->enn_pin_); + LOG_PIN(" STEP Pin: ", this->step_pin_); + LOG_PIN(" DIR Pin: ", this->dir_pin_); + LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_STEPPER(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void TMC22XXStepper::loop() { + if (this->index_pin_ != nullptr) + this->add_index_pulses_(); + if (this->has_reached_target()) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + if (!this->enabled_) + this->set_enabled(true); + } + + if (this->index_pin_ != nullptr) { + this->loop_serial_(); + } else { + this->loop_step_dir_(); + } +} + +void TMC22XXStepper::add_index_pulses_() { + InterruptLock lock; + this->current_position += this->index_store_.pulses; + this->index_store_.pulses = 0; +} + +void TMC22XXStepper::loop_serial_() { + int32_t remaining = this->target_position - this->current_position; + int8_t direction = (remaining > 0) - (remaining < 0); + // Ran past the target, restart the ramp from standstill in the other direction + if (direction != 0 && direction == -this->direction_) + this->current_speed_ = 0.0f; + this->calculate_speed_(micros()); + this->current_speed_ = std::min(this->current_speed_, static_cast(std::abs(remaining)) / APPROACH_TIME_S); + + int32_t vactual = direction * this->speed_to_vactual_(this->current_speed_); + int32_t last = this->get_vactual_(); + if (vactual == last) + return; + if (vactual != 0 && (vactual > 0) == (last > 0) && std::abs(vactual - last) < std::abs(last) / VACTUAL_CHANGE_DIVISOR) + return; + // Pulses that arrive while VACTUAL changes direction are counted in the new direction + this->index_store_.direction = direction; + this->direction_ = direction; + this->write_field(VACTUAL, static_cast(vactual)); +} + +void TMC22XXStepper::loop_step_dir_() { + int32_t direction = this->should_step_(); + if (direction == 0) + return; + if (direction != this->direction_) { + this->dir_pin_->digital_write(direction > 0); + this->direction_ = direction; + delayMicroseconds(1); + } + this->step_state_ = !this->step_state_; + this->step_pin_->digital_write(this->step_state_); +} + +void TMC22XXStepper::on_shutdown() { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); +} + +void TMC22XXStepper::set_enabled(bool enabled) { + if (!enabled) { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); + if (this->index_pin_ != nullptr) { + // Count the pulses up to the stop, so the position is current before the target is set + this->add_index_pulses_(); + this->index_store_.direction = 0; + this->direction_ = 0; + } + // Stop at the current position, so the motor does not run off again when re-enabled + this->target_position = this->current_position; + } + if (this->enn_pin_ != nullptr) { + this->enn_pin_->digital_write(!enabled); + } else { + this->write_field(TOFF, enabled ? this->toff_ : 0); + } + this->enabled_ = enabled; +} + +void TMC22XXStepper::set_microsteps(uint16_t microsteps) { + for (uint8_t mres = 0; mres <= 8; mres++) { + if ((256u >> mres) == microsteps) { + this->write_field(MRES, mres); + return; + } + } + ESP_LOGW(TAG, "Invalid microsteps %u", microsteps); +} + +uint16_t TMC22XXStepper::get_microsteps() { + auto mres = this->read_field(MRES); + if (!mres.has_value() || *mres > 8) + return 0; + return 256u >> *mres; +} + +void TMC22XXStepper::set_run_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IRUN, *scale); +} + +void TMC22XXStepper::set_hold_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IHOLD, *scale); +} + +float TMC22XXStepper::full_scale_current_() { + return (this->vsense_active_ ? 0.180f : 0.325f) / + ((std::isnan(this->rsense_) ? INTERNAL_RSENSE_OHM : this->rsense_) + SENSE_RESISTOR_OFFSET); +} + +float TMC22XXStepper::scale_to_current(uint8_t scale) { + return (std::min(scale, 31) + 1) / 32.0f * this->full_scale_current_() / std::numbers::sqrt2_v; +} + +optional TMC22XXStepper::current_to_scale_(float current) { + if (this->analog_current_scale_) { + ESP_LOGW(TAG, "Current in A is not supported with analog current scale, use irun/ihold"); + return {}; + } + float scale = 32.0f * std::numbers::sqrt2_v * current / this->full_scale_current_() - 1.0f; + if (scale > 31.0f) { + ESP_LOGW(TAG, "%.2f A is above the %.2f A limit, using the limit", current, this->scale_to_current(31)); + return 31; + } + return static_cast(std::max(0.0f, std::round(scale))); +} + +int32_t TMC22XXStepper::speed_to_vactual_(float speed) const { + // VACTUAL is in microsteps per 2^24 clock cycles + return static_cast(std::lround(speed * 16777216.0f / this->clock_frequency_)); +} + +uint32_t *TMC22XXStepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_IHOLD_IRUN: + return &this->ihold_irun_; + case REG_TPOWERDOWN: + return &this->tpowerdown_; + case REG_TPWMTHRS: + return &this->tpwmthrs_; + case REG_VACTUAL: + return &this->vactual_reg_; + default: + return nullptr; + } +} + +bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { + uint8_t datagram[DATAGRAM_SIZE] = {SYNC_BYTE, + this->address_, + static_cast(reg | WRITE_BIT), + static_cast(value >> 24), + static_cast(value >> 16), + static_cast(value >> 8), + static_cast(value), + 0}; + datagram[7] = datagram_crc(datagram, 7); + + while (this->available()) + this->read(); + this->write_array(datagram, DATAGRAM_SIZE); + this->flush(); + // On the single wire bus every byte sent is also received, drop that echo + uint8_t echo[DATAGRAM_SIZE]; + if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->status_clear_warning(); + + if (uint32_t *shadow = this->shadow_register(reg)) + *shadow = value; + return true; +} + +optional TMC22XXStepper::read_register(uint8_t reg) { + if (uint32_t *shadow = this->shadow_register(reg)) + return *shadow; + + uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; + request[3] = datagram_crc(request, 3); + + while (this->available()) + this->read(); + this->write_array(request, READ_REQUEST_SIZE); + this->flush(); + uint8_t reply[DATAGRAM_SIZE]; + if (!this->read_array(reply, READ_REQUEST_SIZE) || !this->read_array(reply, DATAGRAM_SIZE)) { + ESP_LOGV(TAG, "No reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + this->status_clear_warning(); + return encode_uint32(reply[3], reply[4], reply[5], reply[6]); +} + +bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { + // Skip the UART timeout when the driver never answered during setup + if (this->is_failed()) + return false; + auto data = this->read_register(field.reg); + if (!data.has_value()) + return false; + uint32_t mask = field.mask(); + return this->write_register(field.reg, (*data & ~mask) | ((value << field.shift) & mask)); +} + +optional TMC22XXStepper::read_field(const RegisterField &field) { + auto data = this->read_register(field.reg); + if (!data.has_value()) + return {}; + return extract_field(*data, field); +} + +uint32_t TMC22XXStepper::extract_field(uint32_t data, const RegisterField &field) { + uint32_t value = (data & field.mask()) >> field.shift; + if (field.is_signed && field.width < 32) { + uint32_t sign_bit = 1u << (field.width - 1); + value = (value ^ sign_bit) - sign_bit; + } + return value; +} + +} // namespace esphome::tmc22xx diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h new file mode 100644 index 0000000000..8b0c92d352 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -0,0 +1,167 @@ +#pragma once + +#include + +#include "esphome/components/stepper/stepper.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tmc22xx { + +/// A bit field inside one register. +struct RegisterField { + uint8_t reg; + uint8_t shift; + uint8_t width; + bool is_signed; + + constexpr uint32_t mask() const { return ((width >= 32) ? 0xFFFFFFFFu : ((1u << width) - 1u)) << shift; } +}; + +// Registers shared by the TMC22xx family (TMC2202, TMC2208, TMC2209, TMC2224, TMC2225, TMC2226) +static constexpr uint8_t REG_GCONF = 0x00; +static constexpr uint8_t REG_GSTAT = 0x01; +static constexpr uint8_t REG_IOIN = 0x06; +static constexpr uint8_t REG_FACTORY_CONF = 0x07; +static constexpr uint8_t REG_IHOLD_IRUN = 0x10; +static constexpr uint8_t REG_TPOWERDOWN = 0x11; +static constexpr uint8_t REG_TPWMTHRS = 0x13; +static constexpr uint8_t REG_VACTUAL = 0x22; +static constexpr uint8_t REG_CHOPCONF = 0x6C; +static constexpr uint8_t REG_PWMCONF = 0x70; + +static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; +static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; +static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; +static constexpr RegisterField SHAFT{REG_GCONF, 3, 1, false}; +static constexpr RegisterField INDEX_OTPW{REG_GCONF, 4, 1, false}; +static constexpr RegisterField INDEX_STEP{REG_GCONF, 5, 1, false}; +static constexpr RegisterField PDN_DISABLE{REG_GCONF, 6, 1, false}; +static constexpr RegisterField MSTEP_REG_SELECT{REG_GCONF, 7, 1, false}; +static constexpr RegisterField MULTISTEP_FILT{REG_GCONF, 8, 1, false}; +static constexpr RegisterField VERSION{REG_IOIN, 24, 8, false}; +static constexpr RegisterField OTTRIM{REG_FACTORY_CONF, 8, 2, false}; +static constexpr RegisterField IHOLD{REG_IHOLD_IRUN, 0, 5, false}; +static constexpr RegisterField IRUN{REG_IHOLD_IRUN, 8, 5, false}; +static constexpr RegisterField IHOLDDELAY{REG_IHOLD_IRUN, 16, 4, false}; +static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; +static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; +static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; +static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; +static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; +static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; +static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; + +enum StandstillMode : uint8_t { + STANDSTILL_MODE_NORMAL = 0, + STANDSTILL_MODE_FREEWHEELING = 1, + STANDSTILL_MODE_COIL_SHORT_LS = 2, + STANDSTILL_MODE_COIL_SHORT_HS = 3, +}; + +/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. +struct IndexPulseStore { + volatile int32_t pulses{0}; + volatile int8_t direction{0}; + static void gpio_intr(IndexPulseStore *arg); +}; + +/// Base class for Trinamic stepper drivers that are configured over the single wire UART interface. +class TMC22XXStepper : public stepper::Stepper, public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_address(uint8_t address) { this->address_ = address; } + void set_enn_pin(GPIOPin *pin) { this->enn_pin_ = pin; } + void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } + void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } + void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } + void set_rsense(float rsense) { this->rsense_ = rsense; } + void set_vsense(bool vsense) { this->vsense_ = vsense; } + void set_ottrim(uint8_t ottrim) { this->ottrim_ = ottrim; } + void set_analog_current_scale(bool enable) { this->analog_current_scale_ = enable; } + void set_initial_run_current(float current) { this->initial_run_current_ = current; } + void set_initial_hold_current(float current) { this->initial_hold_current_ = current; } + void set_initial_microsteps(uint16_t microsteps) { this->initial_microsteps_ = microsteps; } + + /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. + void set_enabled(bool enabled); + bool is_enabled() const { return this->enabled_; } + + void set_microsteps(uint16_t microsteps); + uint16_t get_microsteps(); + void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } + void set_spreadcycle(bool enable) { this->write_field(EN_SPREADCYCLE, enable); } + void set_inverse_direction(bool inverse) { this->write_field(SHAFT, inverse); } + void set_tpwm_threshold(uint32_t threshold) { this->write_field(TPWMTHRS, threshold); } + + /// Set the motor run or hold current in A RMS. + /// Not supported with analog current scale, use set_irun() and set_ihold() instead. + void set_run_current(float current); + void set_hold_current(float current); + void set_irun(uint8_t irun) { this->write_field(IRUN, irun); } + void set_ihold(uint8_t ihold) { this->write_field(IHOLD, ihold); } + void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } + void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } + void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + /// Convert a current scale (0-31) to A RMS, without analog current scale. + float scale_to_current(uint8_t scale); + + bool write_register(uint8_t reg, uint32_t value); + optional read_register(uint8_t reg); + bool write_field(const RegisterField &field, uint32_t value); + optional read_field(const RegisterField &field); + static uint32_t extract_field(uint32_t data, const RegisterField &field); + + protected: + /// IC version reported in IOIN by the supported chip. + virtual uint8_t expected_version() const = 0; + /// Return the cached value of a write-only register, or nullptr when the register is readable. + virtual uint32_t *shadow_register(uint8_t reg); + optional current_to_scale_(float current); + float full_scale_current_(); + int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } + int32_t speed_to_vactual_(float speed) const; + void add_index_pulses_(); + void loop_serial_(); + void loop_step_dir_(); + + GPIOPin *enn_pin_{nullptr}; + GPIOPin *step_pin_{nullptr}; + GPIOPin *dir_pin_{nullptr}; + InternalGPIOPin *index_pin_{nullptr}; + uint32_t clock_frequency_{12000000}; + // Write-only registers shared by all family members, with their power-on values + uint32_t ihold_irun_{0x00071703}; + uint32_t tpowerdown_{0x14}; + uint32_t tpwmthrs_{0}; + uint32_t vactual_reg_{0}; + // NaN while not configured + float rsense_{NAN}; + float initial_run_current_{NAN}; + float initial_hold_current_{NAN}; + IndexPulseStore index_store_{}; + uint16_t initial_microsteps_{16}; + optional vsense_{}; + optional ottrim_{}; + uint8_t address_{0}; + uint8_t toff_{3}; + uint8_t version_{0}; + int8_t direction_{0}; + bool analog_current_scale_{false}; + bool enabled_{false}; + bool vsense_active_{false}; + bool step_state_{false}; + HighFrequencyLoopRequester high_freq_; +}; + +} // namespace esphome::tmc22xx diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0819a1bfb0..9316e90eda 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -24,6 +24,8 @@ static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; __attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool ESP8266UartComponent::serial0_swapped = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + uint32_t ESP8266UartComponent::get_config() { uint32_t config = 0; @@ -111,6 +113,7 @@ void ESP8266UartComponent::setup() { this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); this->hw_serial_->swap(); ESP8266UartComponent::serial0_in_use = true; + ESP8266UartComponent::serial0_swapped = true; } else #endif // USE_ESP8266_UART_SERIAL #ifdef USE_ESP8266_UART_SERIAL1 @@ -133,6 +136,11 @@ void ESP8266UartComponent::load_settings(bool dump_config) { SerialConfig config = static_cast(get_config()); this->hw_serial_->begin(this->baud_rate_, config); this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); +#ifdef USE_ESP8266_UART_SERIAL + // begin() puts UART0 back on GPIO1/GPIO3, so swap it again like setup() did for GPIO15/GPIO13 + if (this->hw_serial_ == &Serial && ESP8266UartComponent::serial0_swapped) + this->hw_serial_->swap(); +#endif // USE_ESP8266_UART_SERIAL } else { this->sw_serial_->setup(this->tx_pin_, this->rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, this->parity_, this->rx_buffer_size_); diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 3d4a2ba85b..a29373a9d4 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -92,7 +92,8 @@ class ESP8266UartComponent final : public UARTComponent, public Component { ESP8266SoftwareSerial *sw_serial_{nullptr}; private: - static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_swapped; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; } // namespace esphome::uart diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 81149f056b..5957870e7f 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,9 +1,11 @@ +from ipaddress import IPv4Address, IPv6Address from typing import Any from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg from esphome.components.const import CONF_ON_RECEIVE +from esphome.components.network import DOMAIN as NETWORK_DOMAIN from esphome.components.packet_transport import ( CONF_BINARY_SENSORS, CONF_ENCRYPTION, @@ -13,9 +15,16 @@ from esphome.components.packet_transport import ( CONF_SENSORS, ) import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID +from esphome.const import ( + CONF_DATA, + CONF_ENABLE_IPV6, + CONF_ID, + CONF_PORT, + CONF_TRIGGER_ID, +) from esphome.core import ID from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -63,13 +72,80 @@ RELOCATED = { } -def _consume_udp_sockets(config: ConfigType) -> ConfigType: - """Register socket needs for UDP component.""" +def _no_scope_id(addr: IPv4Address | IPv6Address) -> IPv4Address | IPv6Address: + """Python accepts a scope id, which neither inet_pton nor lwIP's inet6_aton takes.""" + if isinstance(addr, IPv6Address) and addr.scope_id is not None: + raise cv.Invalid( + f"{addr} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + return addr + + +def _address(value: Any) -> IPv4Address | IPv6Address: + addr = _no_scope_id(cv.ipaddress(value)) + # A link-local destination needs a scope id, which the device cannot pick on every platform + if isinstance(addr, IPv6Address) and addr.is_link_local: + raise cv.Invalid( + f"{addr} is link-local; use a multicast, unique local or global address" + ) + return addr + + +def _listen_address(value: Any) -> IPv4Address | IPv6Address: + """Accept an IPv4 multicast/broadcast or an IPv6 multicast address, with a family-specific error.""" + if ":" not in str(value): + return cv.ipv4address_multi_broadcast(value) + addr = _no_scope_id(cv.ipv6address(value)) + if not addr.is_multicast: + raise cv.Invalid(f"{value} is not an IPv6 multicast address") + return addr + + +def _final_validate(config: ConfigType) -> None: from esphome.components import socket - # UDP uses up to 2 sockets: 1 broadcast + 1 listen - # Whether each is used depends on code generation, so register worst case - socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config) + full_config = fv.full_config.get() + ipv6_enabled = full_config.get(NETWORK_DOMAIN, {}).get(CONF_ENABLE_IPV6, False) + raw_lwip = ( + full_config.get(socket.DOMAIN, {}).get(socket.CONF_IMPLEMENTATION) + == socket.IMPLEMENTATION_LWIP_TCP + ) + for key, addresses in ( + (CONF_ADDRESSES, config[CONF_ADDRESSES]), + (CONF_LISTEN_ADDRESS, [config[CONF_LISTEN_ADDRESS]]), + ): + for addr in addresses: + if addr.version != 6: + continue + if raw_lwip: + raise cv.Invalid( + f"IPv6 address '{addr}' is not supported on this platform", [key] + ) + if not ipv6_enabled: + raise cv.Invalid( + f"IPv6 address '{addr}' requires 'enable_ipv6: true' in the 'network' component", + [key], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]: + return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6] + + +def _has_ipv4_address(config: ConfigType) -> bool: + return any(addr.version == 4 for addr in config[CONF_ADDRESSES]) + + +def _consume_udp_sockets(config: ConfigType) -> ConfigType: + """Reserve the listen socket plus one send socket per address family in use.""" + from esphome.components import socket + + count = 1 + _has_ipv4_address(config) + bool(_ipv6_addresses(config)) + socket.consume_sockets(count, "udp", socket.SocketType.UDP)(config) return config @@ -88,9 +164,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional( CONF_LISTEN_ADDRESS, default="255.255.255.255" - ): cv.ipv4address_multi_broadcast, + ): _listen_address, cv.Optional(CONF_ADDRESSES, default=["255.255.255.255"]): cv.ensure_list( - cv.ipv4address, + _address ), cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( { @@ -125,6 +201,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) + if ipv6_addresses := _ipv6_addresses(config): + cg.add_define("USE_UDP_IPV6_SEND") + cg.add(var.set_ipv6_send(any(addr.is_multicast for addr in ipv6_addresses))) + if not _has_ipv4_address(config): + cg.add(var.set_ipv4_send(False)) # Shared flash table ended by nullptr, so the component stores only a pointer. if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: cg.add( diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h index a6a40829b1..0936aa3ab5 100644 --- a/esphome/components/udp/automation.h +++ b/esphome/components/udp/automation.h @@ -13,7 +13,7 @@ template class UDPWriteAction final : public Action, publ TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - // One write per packet: WiFiUDP appends all or nothing, so a failed allocation never sends a partial packet + // One sendto per packet, so a failed allocation never sends a partial packet this->data_.template visit( [this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...); } diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 3918ddc1fa..e143f93a4b 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -8,43 +8,95 @@ namespace esphome::udp { ESPHOME_LOG_TAG(TAG, "udp"); +static constexpr uint32_t NETWORK_POLL_ID = 0; +static constexpr uint32_t NETWORK_POLL_INTERVAL_MS = 1000; void UDPComponent::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + size_t address_count = 0; + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) + address_count++; + this->sockaddrs_.init(address_count); for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { const char *address = *it; - struct sockaddr saddr {}; - if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { + SockaddrEntry entry{}; + if (socket::set_sockaddr(&entry.sa, sizeof(entry), address, this->broadcast_port_) == 0) { ESP_LOGW(TAG, "Invalid address %s", address); // A dropped address silently receives nothing; surface the misconfiguration this->status_set_warning(LOG_STR("invalid address")); continue; } - this->sockaddrs_.push_back(saddr); + this->sockaddrs_.push_back(entry); } // set up broadcast socket if (this->should_broadcast_) { - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); - if (this->broadcast_socket_ == nullptr) { - this->status_set_error(LOG_STR("Could not create socket")); - this->mark_failed(); - return; +#ifdef USE_UDP_IPV6_SEND + if (this->ipv4_send_) +#endif + { + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); + if (this->broadcast_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); + this->mark_failed(); + return; + } + int enable = 1; + auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); + // we can still continue + } + err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); + } } - int enable = 1; - auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); - // we can still continue - } - err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set broadcast")); +#ifdef USE_UDP_IPV6_SEND + if (this->ipv6_send_) { + this->send_socket_v6_ = socket::socket_udp_send(AF_INET6, IPPROTO_IP); + if (this->send_socket_v6_ == nullptr) { + this->status_set_error(LOG_STR("Could not create IPv6 socket")); + this->mark_failed(); + return; + } + // Unicast needs no egress interface; multicast does + if (this->ipv6_multicast_) + this->poll_pending_ |= POLL_MCAST_IF; } +#endif } // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + struct sockaddr_storage server {}; + socklen_t server_len; + if (this->listen_address_ != nullptr) { + server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // A multicast join needs a socket of the group's address family + this->listen_socket_ = socket::socket_udp_loop_monitored(server.ss_family, IPPROTO_IP); + this->poll_pending_ |= POLL_JOIN; +#ifdef USE_SOCKET_IPV6 + // An IPv6 group binds after the join, which supplies the scope id link-local groups need + if (server.ss_family == AF_INET6) + this->poll_pending_ |= POLL_BIND; +#endif + } else { +#ifdef USE_SOCKET_IPV6 + server_len = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET6, IPPROTO_IP); + // Clear IPV6_V6ONLY so IPv4 datagrams also arrive, as mapped addresses + int v6only = 0; + if (this->listen_socket_ != nullptr && + this->listen_socket_->setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) != 0) { + ESP_LOGW(TAG, "Failed to clear IPV6_V6ONLY: errno %d", errno); + this->status_set_warning(LOG_STR("IPv4 datagrams may not be received")); + } +#else + // Must stay IPv4: raw lwIP can have USE_NETWORK_IPV6 without IPv6 sockets + server_len = socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), "0.0.0.0", this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); +#endif + } if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -63,68 +115,102 @@ void UDPComponent::setup() { this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } - struct sockaddr_in server {}; - server.sin_family = AF_INET; - server.sin_addr.s_addr = ESPHOME_INADDR_ANY; - server.sin_port = htons(this->listen_port_); - - if (this->listen_address_.has_value()) { - // Only 16 bytes needed for IPv4, but use standard size for consistency - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - this->listen_address_.value().str_to(addr_buf); - struct ip_mreq imreq = {}; - imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; - inet_aton(addr_buf, &imreq.imr_multiaddr); - server.sin_addr.s_addr = imreq.imr_multiaddr.s_addr; - ESP_LOGD(TAG, "Join multicast %s", addr_buf); - err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); - if (err < 0) { - ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - this->status_set_error(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); + if ((this->poll_pending_ & POLL_BIND) == 0) { + err = this->listen_socket_->bind((struct sockaddr *) &server, server_len); + if (err != 0) { + ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + this->status_set_error(LOG_STR("Unable to bind socket")); this->mark_failed(); return; } } + } + if (this->poll_pending_ != 0) { + // Joins and the IPv6 egress interface need the network up; RP2040 has no interface before then + this->set_interval(NETWORK_POLL_ID, NETWORK_POLL_INTERVAL_MS, [this]() { + if (network::is_connected() && this->network_poll_()) + this->cancel_interval(NETWORK_POLL_ID); + }); + } +} - err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); - if (err != 0) { - ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); - this->status_set_error(LOG_STR("Unable to bind socket")); - this->mark_failed(); - return; +bool UDPComponent::network_poll_() { + bool ok = true; + if (this->poll_pending_ & POLL_JOIN) { +#ifdef USE_SOCKET_IPV6 + uint32_t *if_index = &this->mcast_ifindex_; +#else + uint32_t *if_index = nullptr; +#endif + if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_, if_index)) { + ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); + this->poll_pending_ &= ~POLL_JOIN; + this->poll_failed_ &= ~POLL_JOIN; + } else { + this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group")); + ok = false; + } + } +#ifdef USE_SOCKET_IPV6 + if (ok && (this->poll_pending_ & POLL_BIND)) { + struct sockaddr_storage server {}; + socklen_t server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // POSIX bind() on a link-local group needs it; lwIP ignores it + reinterpret_cast(&server)->sin6_scope_id = this->mcast_ifindex_; + if (this->listen_socket_->bind((struct sockaddr *) &server, server_len) == 0) { + this->poll_pending_ &= ~POLL_BIND; + this->poll_failed_ &= ~POLL_BIND; + } else { + this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket")); + ok = false; } } #endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - // 8266 and RP2040 `Duino - for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { - const char *address = *it; - auto ipaddr = IPAddress(); - if (!ipaddr.fromString(address)) { - ESP_LOGW(TAG, "Invalid address %s", address); - this->status_set_warning(LOG_STR("invalid address")); - continue; +#ifdef USE_UDP_IPV6_SEND + // Only an IPv6 group's join supplies the index, so a failing IPv4 join must not hold back sends + const bool index_pending = (this->poll_pending_ & (POLL_JOIN | POLL_BIND)) == (POLL_JOIN | POLL_BIND); + if ((this->poll_pending_ & POLL_MCAST_IF) && !index_pending) { + // Index 0 picks the first eligible interface + if (socket::set_ipv6_multicast_if(this->send_socket_v6_.get(), this->mcast_ifindex_)) { + this->poll_pending_ &= ~POLL_MCAST_IF; + this->poll_failed_ &= ~POLL_MCAST_IF; + } else { + this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface")); + ok = false; } - this->ipaddrs_.push_back(ipaddr); } - if (this->should_listen_) - this->udp_client_.begin(this->listen_port_); #endif + if (!ok) + return false; + if (this->poll_warning_) { + this->poll_warning_ = false; + this->status_clear_warning(); + } + return true; +} + +void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) { + // Log the first failure of each step only; the poll keeps retrying every second + if (this->poll_failed_ & step) + return; + this->poll_failed_ |= step; + ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), errno); + // One warning bit: only a warning raised here is cleared on success, one from setup() stays + if (!this->status_has_warning()) { + this->status_set_warning(what); + this->poll_warning_ = true; + } } void UDPComponent::loop() { if (this->should_listen_) { std::array buf; for (;;) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + if (!this->listen_socket_->ready()) + break; auto len = this->listen_socket_->read(buf.data(), buf.size()); -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto len = this->udp_client_.parsePacket(); - if (len > 0) - len = this->udp_client_.read(buf.data(), buf.size()); -#endif if (len <= 0) break; size_t packet_len = static_cast(len); @@ -144,9 +230,8 @@ void UDPComponent::dump_config() { const char *address = *it; ESP_LOGCONFIG(TAG, " Address: %s", address); } - if (this->listen_address_.has_value()) { - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_.value().str_to(addr_buf)); + if (this->listen_address_ != nullptr) { + ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_); } ESP_LOGCONFIG(TAG, " Broadcasting: %s\n" @@ -155,26 +240,27 @@ void UDPComponent::dump_config() { } void UDPComponent::send_packet(const uint8_t *data, size_t size) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - for (const auto &saddr : this->sockaddrs_) { - auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr)); + if (this->is_failed()) + return; // a send socket may be missing + for (const auto &entry : this->sockaddrs_) { +#ifdef USE_UDP_IPV6_SEND + const bool v6 = entry.sa.sa_family == AF_INET6; + // Until the egress interface is set, a multicast packet would leave on the wrong one + if (v6 && (this->poll_pending_ & POLL_MCAST_IF) && entry.in6.sin6_addr.s6_addr[0] == 0xff) { + ESP_LOGV(TAG, "Egress interface not set yet, multicast packet dropped"); + continue; + } + socket::UDPSendSocket *sock = v6 ? this->send_socket_v6_.get() : this->broadcast_socket_.get(); + socklen_t sockaddr_size = v6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in); +#else + socket::UDPSendSocket *sock = this->broadcast_socket_.get(); + socklen_t sockaddr_size = sizeof(sockaddr_in); +#endif + auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size); if (result < 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } } -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto iface = IPAddress(0, 0, 0, 0); - for (const auto &saddr : this->ipaddrs_) { - if (this->udp_client_.beginPacketMulticast(saddr, this->broadcast_port_, iface, 128) != 0) { - this->udp_client_.write(data, size); - auto result = this->udp_client_.endPacket(); - if (result == 0) { - ESP_LOGW(TAG, "udp.write() error"); - } - } - } -#endif } } // namespace esphome::udp diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 17e27fb770..b4dd4b2ac4 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -3,13 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_NETWORK #include "esphome/core/helpers.h" -#include "esphome/components/network/ip_address.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP -#include -#endif #include #include #include @@ -22,11 +16,21 @@ class UDPComponent final : public Component { public: /// nullptr-terminated table of addresses; must outlive the component. void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } - void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } + /// Multicast group literal; must outlive the component. + void set_listen_address(const char *listen_addr) { this->listen_address_ = listen_addr; } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_listen() { this->should_listen_ = true; } +#ifdef USE_UDP_IPV6_SEND + /// This instance has IPv6 destinations; multicast ones also need an egress interface. + void set_ipv6_send(bool multicast) { + this->ipv6_send_ = true; + this->ipv6_multicast_ = multicast; + } + /// An instance whose destinations are all IPv6 needs no IPv4 send socket + void set_ipv4_send(bool ipv4_send) { this->ipv4_send_ = ipv4_send; } +#endif template void add_listener(F &&listener) { this->packet_listeners_.add(std::forward(listener)); } void setup() override; void loop() override; @@ -36,24 +40,43 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: + enum PollStep : uint8_t { POLL_JOIN = 1, POLL_BIND = 2, POLL_MCAST_IF = 4 }; + + union SockaddrEntry { + sockaddr sa; +#ifdef USE_UDP_IPV6_SEND + sockaddr_in6 in6; // sizes the storage for IPv6 destinations +#endif + }; + + /// One pass of the network poll; true once nothing is left to do. + bool network_poll_(); + void set_poll_warning_(PollStep step, const LogString *what); + + CallbackManager)> packet_listeners_{}; + std::unique_ptr broadcast_socket_ = nullptr; + std::unique_ptr listen_socket_ = nullptr; +#ifdef USE_UDP_IPV6_SEND + std::unique_ptr send_socket_v6_ = nullptr; +#endif + FixedVector sockaddrs_{}; + const char *const *addresses_{nullptr}; + const char *listen_address_{nullptr}; +#ifdef USE_SOCKET_IPV6 + uint32_t mcast_ifindex_{0}; +#endif uint16_t listen_port_{}; uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; - CallbackManager)> packet_listeners_{}; - -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_ = nullptr; - std::unique_ptr listen_socket_ = nullptr; - std::vector sockaddrs_{}; + uint8_t poll_pending_{}; // PollStep bits + uint8_t poll_failed_{}; // PollStep bits already logged + bool poll_warning_{}; +#ifdef USE_UDP_IPV6_SEND + bool ipv4_send_{true}; + bool ipv6_send_{}; + bool ipv6_multicast_{}; #endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - std::vector ipaddrs_{}; - WiFiUDP udp_client_{}; -#endif - const char *const *addresses_{nullptr}; - - optional listen_address_{}; }; } // namespace esphome::udp diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index 5e756d0057..f5d15f3cfa 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -1,33 +1,28 @@ import esphome.codegen as cg -from esphome.components import button +from esphome.components import button, socket import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE from esphome.types import ConfigType +AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] -def AUTO_LOAD() -> list[str]: - if CORE.is_esp8266 or CORE.is_rp2: - return [] - return ["socket"] - - CONF_TARGET_MAC_ADDRESS = "target_mac_address" wake_on_lan_ns = cg.esphome_ns.namespace("wake_on_lan") WakeOnLanButton = wake_on_lan_ns.class_("WakeOnLanButton", button.Button, cg.Component) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( button.button_schema(WakeOnLanButton) .extend(cv.COMPONENT_SCHEMA) .extend( { cv.Required(CONF_TARGET_MAC_ADDRESS): cv.mac_address, } - ) + ), + socket.consume_sockets(1, "wake_on_lan", socket.SocketType.UDP), ) diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index 52dfe98aad..3815312c68 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,7 +1,6 @@ #include "wake_on_lan.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" -#include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" namespace esphome::wake_on_lan { @@ -25,12 +24,15 @@ void WakeOnLanButton::dump_config() { } void WakeOnLanButton::press_action() { + if (this->broadcast_socket_ == nullptr) { + ESP_LOGW(TAG, "Socket not available"); + return; + } if (!network::is_connected()) { ESP_LOGW(TAG, "Network not connected"); return; } ESP_LOGI(TAG, "Sending Wake-on-LAN Packet"); -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) struct sockaddr_storage saddr {}; auto addr_len = socket::set_sockaddr(reinterpret_cast(&saddr), sizeof(saddr), "255.255.255.255", this->port_); @@ -47,29 +49,10 @@ void WakeOnLanButton::press_action() { addr_len) <= 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } -#else - IPAddress broadcast = IPAddress(255, 255, 255, 255); - for (auto ip : esphome::network::get_ip_addresses()) { - if (ip.is_ip4()) { - if (this->udp_client_.beginPacketMulticast(broadcast, 9, ip, 128) != 0) { - this->udp_client_.write(PREFIX, 6); - for (size_t i = 0; i < 16; i++) { - this->udp_client_.write(macaddr_, 6); - } - if (this->udp_client_.endPacket() != 0) - return; - ESP_LOGW(TAG, "WOL broadcast failed"); - return; - } - } - } - ESP_LOGW(TAG, "No ip4 addresses to broadcast to"); -#endif } void WakeOnLanButton::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -85,7 +68,6 @@ void WakeOnLanButton::setup() { if (err != 0) { this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } -#endif } } // namespace esphome::wake_on_lan diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index cef60c54f8..6f89754f56 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -2,13 +2,9 @@ #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" +#include "esphome/components/socket/socket.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) -#include "esphome/components/socket/socket.h" -#else -#include "WiFiUdp.h" -#endif namespace esphome::wake_on_lan { @@ -21,11 +17,7 @@ class WakeOnLanButton final : public button::Button, public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_{}; -#else - WiFiUDP udp_client_{}; -#endif + std::unique_ptr broadcast_socket_{}; void press_action() override; uint16_t port_{9}; uint8_t macaddr_[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 4f233e9965..c48c72c06e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import ( + CONF_AP, CONF_AUTH, CONF_COMPRESSION, CONF_CSS_INCLUDE, @@ -24,15 +25,19 @@ from esphome.const import ( CONF_JS_URL, CONF_LOCAL, CONF_LOG, + CONF_MANUAL_IP, CONF_NAME, + CONF_NETWORKS, CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_STATIC_IP, CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, CONF_WEB_SERVER_ID, + CONF_WIFI, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -47,7 +52,21 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["json", "web_server_base"] + +def AUTO_LOAD() -> list[str]: + # No config parameter: a dynamic auto-load would satisfy ota.web_server's dependency too late + auto_load = ["json", "web_server_base"] + # The AP mode DNS server needs socket; CORE.raw_config already has a wifi block from a package + wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None + if ( + CORE.is_esp32 + and wifi is not None + and (not isinstance(wifi, dict) or CONF_AP in wifi) + ): + auto_load.append("socket") + return auto_load + + DOMAIN = "web_server" AUTH_TYPE_BASIC = "basic" @@ -211,9 +230,6 @@ def _final_validate_sorting(config: ConfigType) -> None: ) -FINAL_VALIDATE_SCHEMA = _final_validate_sorting - - def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" from esphome.components import socket @@ -340,6 +356,108 @@ async def add_entity_config(entity: MockObj, config: ConfigType) -> None: ) +def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool: + """AP only: an access point and no network to join, so the device is only ever reached + through its own AP.""" + return ( + wifi_config is not None + and CONF_AP in wifi_config + and not wifi_config.get(CONF_NETWORKS) + ) + + +def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool: + """Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet + for the hosted page. Version 1 has no local mode.""" + if (local := config.get(CONF_LOCAL)) is not None: + return local + return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) + + +def _captive_portal_configured(full_config: ConfigType) -> bool: + # Imported here: captive_portal pulls in wifi, which sensor (through web_server) must not + from esphome.components.captive_portal import DOMAIN as CAPTIVE_PORTAL_DOMAIN + + return CAPTIVE_PORTAL_DOMAIN in full_config + + +def serve_captive(config: ConfigType, full_config: ConfigType) -> bool: + """Serve the embedded interface as a captive portal while the AP is up, unless captive_portal + owns that role. Port 80 only: the OS probes and the DHCP portal URI never use another port.""" + wifi_config = full_config.get(CONF_WIFI) + return ( + not _captive_portal_configured(full_config) + and config[CONF_PORT] == 80 + and wifi_config is not None + and CONF_AP in wifi_config + and serve_local(config, wifi_config) + ) + + +def _final_validate_ap_mode(config: ConfigType) -> None: + full_config = fv.full_config.get() + wifi_config = full_config.get(CONF_WIFI) + captive = serve_captive(config, full_config) + local = serve_local(config, wifi_config) + ap_only = wifi_is_ap_only(wifi_config) + if captive: + web_server_base.consume_captive_dns_sockets(config, DOMAIN) + # Surface behavior that the config does not spell out. + if local and CONF_LOCAL not in config: + _LOGGER.info( + "WiFi is AP only: embedding the web interface in the firmware " + "(local: true, roughly 13 KB of flash for version 2, 78 KB for version 3)%s. " + "Set 'local: false' to load it from the internet instead.", + " and serving it as a captive portal on the access point" + if captive + else "", + ) + elif captive: + _LOGGER.info( + "web_server will act as a captive portal while the %saccess point is active.", + "" if ap_only else "fallback ", + ) + # An embedded interface on an AP (AP only, or a fallback with local: true) that is not + # on port 80 cannot be a captive portal; say so, unless captive_portal has that role. + if ( + local + and config[CONF_PORT] != 80 + and wifi_config is not None + and CONF_AP in wifi_config + and not _captive_portal_configured(full_config) + ): + ap_ip = "192.168.4.1" + if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None: + ap_ip = str(manual_ip[CONF_STATIC_IP]) + _LOGGER.warning( + "web_server uses port %d, so the interface cannot open automatically on the " + "%saccess point (captive portal detection only works on port 80); open " + "http://%s:%d/ manually, or remove 'port:' to use 80.", + config[CONF_PORT], + "" if ap_only else "fallback ", + ap_ip, + config[CONF_PORT], + ) + if ap_only and not local: + _LOGGER.warning( + "WiFi is AP only and the web_server interface is loaded from the internet, " + "which browsers on the access point usually cannot reach; the page stays " + "blank. %s so the interface is embedded in the firmware.", + "Remove 'local: false'" + if config.get(CONF_LOCAL) is False + else "Migrate to version 2 or 3", + ) + + +def _final_validate(config: ConfigType) -> None: + # Called one after the other rather than via cv.All: these return None. + _final_validate_sorting(config) + _final_validate_ap_mode(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) @@ -459,8 +577,12 @@ async def to_code(config: ConfigType) -> None: # include_internal_ is false in C++; only emit the setter to turn it on. if config[CONF_INCLUDE_INTERNAL]: cg.add(var.set_include_internal(True)) - if CONF_LOCAL in config and config[CONF_LOCAL]: + if serve_local(config, CORE.config.get(CONF_WIFI)): cg.add_define("USE_WEBSERVER_LOCAL") + if serve_captive(config, CORE.config): + # AP mode: DNS server plus redirect of unknown URLs so phones open the interface + cg.add_define("USE_WEBSERVER_CAPTIVE") + web_server_base.add_captive_dns_library() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_WEBSERVER_GZIP") diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 3cf1d9802e..8a668ae0fc 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -9,12 +9,6 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) -#include -#endif -#endif // USE_ARDUINO - #if USE_ESP32 using PlatformString = std::string; #elif USE_ARDUINO @@ -64,6 +58,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + /// Abort and drop the backend, releasing the lock. + void abort_session_(); + /// Reply "busy" without touching the running upload. + void refuse_(AsyncWebServerRequest *request); uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -72,6 +70,9 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + /// Request that owns ota_backend_. + const AsyncWebServerRequest *ota_request_{nullptr}; + const AsyncWebServerRequest *refused_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -118,38 +119,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // fires a separate start-marker call with data==nullptr/len==0 before the // first real chunk; gate on len>0 so we only trigger once per upload.) if (index == 0 && len > 0) { - // If a previous upload was interrupted (e.g. client closed the tab, TCP - // reset) the backend from that session may still be open. Tear it down - // so flash state doesn't get concatenated with the new image (which can - // produce a technically-valid-sized but corrupted firmware that bricks - // the device once it reboots). + // A stalled upload times out and onDisconnect() frees it, so refusing can't block for long. if (this->ota_backend_) { - ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); - this->ota_backend_->abort(); -#ifdef USE_OTA_STATE_LISTENER - // Notify listeners that the previous session was aborted before the new one starts. - this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); -#endif - this->ota_backend_.reset(); + ESP_LOGW(TAG, "OTA upload refused: another upload is in progress"); + this->refuse_(request); + return; } - // Initialize OTA on first call - this->ota_init_(filename.c_str()); - -#ifdef USE_OTA_STATE_LISTENER - // Notify OTA started - use notify_state_deferred_ since we're in web server task - this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); -#endif - - // Platform-specific pre-initialization -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) - if (Update.isRunning()) { - Update.abort(); - } -#endif -#endif // USE_ARDUINO - this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); @@ -164,6 +140,22 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // is unknown (contentLength includes multipart overhead) // Pass 0 to indicate unknown size error_code = this->ota_backend_->begin(0); + if (error_code == ota::OTA_RESPONSE_ERROR_UPDATE_BUSY) { + // Nothing started: no state notifications. + ESP_LOGW(TAG, "OTA upload refused: another update is in progress"); + this->ota_backend_.reset(); + this->refuse_(request); + return; + } + + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); +#endif + if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); @@ -172,9 +164,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif return; } + + // An interrupted upload gets no final chunk. + this->ota_request_ = request; + request->onDisconnect([this, request]() { + if (this->ota_request_ == request && this->ota_backend_) { + ESP_LOGW(TAG, "OTA upload interrupted; aborting"); + this->abort_session_(); + } + }); } - if (!this->ota_backend_) { + if (!this->ota_backend_ || request != this->ota_request_) { return; } @@ -185,6 +186,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -212,28 +214,54 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); + // Reset the platform updater before the lock is released. + this->ota_backend_->abort(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } +void OTARequestHandler::abort_session_() { + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + this->ota_request_ = nullptr; +} + +void OTARequestHandler::refuse_(AsyncWebServerRequest *request) { + this->refused_request_ = request; + request->onDisconnect([this, request]() { + if (this->refused_request_ == request) + this->refused_request_ = nullptr; + }); +} + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result + const bool refused = request == this->refused_request_; + if (refused) + this->refused_request_ = nullptr; #ifdef USE_ESP8266 static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char UPDATE_BUSY[] PROGMEM = "Update Failed! Another update is in progress, retry later."; static const char TEXT_PLAIN[] PROGMEM = "text/plain"; static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; - const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + const char *msg = refused ? UPDATE_BUSY : (this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED); response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else - const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + const char *msg = refused ? "Update Failed! Another update is in progress, retry later." + : this->ota_success_ ? "Update Successful!" + : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 323857c37e..b0da010561 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -45,6 +45,10 @@ #include "esphome/components/radio_frequency/radio_frequency.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/wifi/wifi_component.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -57,8 +61,25 @@ namespace esphome::web_server { ESPHOME_LOG_TAG(TAG, "web_server"); +using json::linked; + +#ifdef USE_STORE_LOG_STR_IN_FLASH // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +#else +// Without the define a LogString is in RAM and can be linked +[[maybe_unused]] static JsonString json_state_str(const LogString *s) { return linked(LOG_STR_ARG(s)); } +#endif + +// get_icon_to() and get_device_class_to() return a table pointer, except on ESP8266 where they +// copy out of PROGMEM into the caller's buffer +[[maybe_unused]] static JsonString json_table_str(const char *s) { +#ifdef USE_ESP8266 + return JsonString(s); +#else + return linked(s); +#endif +} // One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the // table needs no RAM on ESP8266. @@ -348,7 +369,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("name")] = linked(group.second.name); root[ESPHOME_F("sorting_weight")] = group.second.weight; auto group_msg = builder.serialize(); @@ -371,7 +392,15 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou } #endif -WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +#ifdef USE_WEBSERVER_CAPTIVE +WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { +#ifdef USE_WEBSERVER_CAPTIVE + global_web_server = this; +#endif +} // Kept out of the callers so the 64 bit division is emitted once __attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } @@ -380,7 +409,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); + root[ESPHOME_F("title")] = linked(App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name()); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); root[ESPHOME_F("comment")] = static_cast(comment_buffer); @@ -412,6 +441,11 @@ void WebServer::setup() { this->base_->add_handler(&this->events_); #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_CAPTIVE + // Not-found fallback (outside the auth middleware): the OS captive portal probes hit + // arbitrary URLs and must get the redirect without credentials. + this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *request) { this->handle_not_found_(request); }); +#endif // OTA is now handled by the web_server OTA platform @@ -427,16 +461,54 @@ void WebServer::setup() { }); } void WebServer::loop() { - // No SSE clients connected; stop looping until a new client connects via + bool keep_looping = this->events_.loop(); +#ifdef USE_WEBSERVER_CAPTIVE + this->dns_.loop(); + keep_looping |= this->dns_.is_running(); +#endif + // No SSE clients connected (and no captive DNS to serve); stop looping until a new client connects via // enable_loop_soon_any_context(). This is safe because: // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() // - deferrable_send_state early-outs when no clients are connected // - try_send_nodefer (log, ping) iterates sessions which are empty // - REST API handlers use defer() which runs via the Scheduler - if (!this->events_.loop()) + if (!keep_looping) this->disable_loop(); } +#ifdef USE_WEBSERVER_CAPTIVE +void WebServer::start_captive() { + // Avoid repeating the log and enable_loop on a second start + if (this->dns_.is_running()) + return; + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + if (!this->dns_.start(ip)) { + ESP_LOGW(TAG, "AP mode: captive DNS failed to start; open http://%s/ manually", ip.str_to(ip_buf)); + return; + } + this->enable_loop(); + ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf)); +} + +void WebServer::end_captive() { this->dns_.stop(); } + +void WebServer::handle_not_found_(AsyncWebServerRequest *request) { + // OS captive portal probe while the AP is up: a redirect rather than the page itself, because + // the interface resolves its /events and REST paths relative to the page URL. + if (this->dns_.is_running() && request->method() == HTTP_GET) { + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + wifi::global_wifi_component->wifi_soft_ap_ip().str_to(ip_buf); + // Captive mode requires port 80 (enforced at validation), so no port suffix + char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1]; + buf_append_printf(location, sizeof(location), 0, "http://%s/", ip_buf); + request->redirect(location); + return; + } + request->send(404); +} +#endif + #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { @@ -616,17 +688,16 @@ static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, Js root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("domain")] = prefix; - // Use .c_str() to avoid instantiating set template (saves ~24B) - root[ESPHOME_F("name")] = name.c_str(); + root[ESPHOME_F("domain")] = linked(prefix, prefix_len); + root[ESPHOME_F("name")] = linked(name); #ifdef USE_DEVICES if (device_name) { - root[ESPHOME_F("device")] = device_name; + root[ESPHOME_F("device")] = linked(device_name, device_len); } #endif #ifdef USE_ENTITY_ICON char icon_buf[MAX_ICON_LENGTH]; - root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); + root[ESPHOME_F("icon")] = json_table_str(obj->get_icon_to(icon_buf)); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); @@ -695,7 +766,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_ if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); } } #endif @@ -734,7 +805,9 @@ void WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::strin json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "text_sensor", value.c_str(), value.c_str(), start_config); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString state(value.c_str(), value.size()); + set_json_icon_state_value(root, obj, "text_sensor", state, state, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1041,9 +1114,9 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("effects")].to(); - opt.add("None"); + opt.add(linked("None")); for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); + opt.add(linked(option->get_name())); } this->add_sorting_info_(root, obj); } @@ -1109,7 +1182,7 @@ void WebServer::cover_all_json_generator(WebServer *web_server, void *source, js void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "cover", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); @@ -1173,9 +1246,14 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - const char *val_str = std::isnan(value) ? "\"NaN\"" : (value_accuracy_to_buf(val_buf, value, accuracy), val_buf); - const char *state_str = - std::isnan(value) ? "NA" : (value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref), state_buf); + JsonString val_str = linked("\"NaN\""); + JsonString state_str = linked("NA"); + if (!std::isnan(value)) { + value_accuracy_to_buf(val_buf, value, accuracy); + value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref); + val_str = JsonString(val_buf); + state_str = JsonString(state_buf); + } set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { // ArduinoJson copies the string immediately, so we can reuse val_buf @@ -1184,7 +1262,7 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ root[ESPHOME_F("step")] = (value_accuracy_to_buf(val_buf, obj->traits.get_step(), accuracy), val_buf); root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); this->add_sorting_info_(root, obj); } } @@ -1412,14 +1490,16 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail json::JsonBuilder &builder) { JsonObject root = builder.root(); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString copied_value(value.c_str(), value.size()); // A password entity shows the mask and prefills the input with nothing, so the secret never // reaches the JSON and the mask cannot be written back as the value const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; - set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), - start_config); + set_json_icon_state_value(root, obj, "text", password ? linked("********") : copied_value, + password ? linked("") : copied_value, start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); - root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); + root[ESPHOME_F("pattern")] = linked(obj->traits.get_pattern_c_str()); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); @@ -1475,12 +1555,12 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st json::JsonBuilder &builder) { JsonObject root = builder.root(); - // value points to null-terminated string literals from codegen (via current_option()) - set_json_icon_state_value(root, obj, "select", value.c_str(), value.c_str(), start_config); + const JsonString state = linked(value); + set_json_icon_state_value(root, obj, "select", state, state, start_config); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { - opt.add(option); + opt.add(linked(option)); } this->add_sorting_info_(root, obj); } @@ -1574,7 +1654,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_fan_modes().empty()) { JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); + opt.add(linked(custom_fan_mode)); } if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); @@ -1589,7 +1669,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); + opt.add(linked(custom_preset)); } root[ESPHOME_F("max_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_max_temperature(), target_accuracy), temp_buf); @@ -1611,13 +1691,13 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = linked(obj->get_custom_fan_mode()); } if (traits.get_supports_presets() && obj->preset.has_value()) { root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = linked(obj->get_custom_preset()); } if (traits.get_supports_swing_modes()) { root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); @@ -1788,7 +1868,7 @@ void WebServer::valve_all_json_generator(WebServer *web_server, void *source, js void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "valve", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); @@ -1936,9 +2016,8 @@ void WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail st JsonObject root = builder.root(); const auto mode = obj->get_mode(); - ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); - - set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); + set_json_icon_state_value(root, obj, "water_heater", json_state_str(water_heater::water_heater_mode_to_string(mode)), + mode, start_config); auto traits = obj->get_traits(); @@ -2209,15 +2288,15 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root[ESPHOME_F("event_type")] = event_type; + root[ESPHOME_F("event_type")] = linked(event_type); } if (start_config == DETAIL_ALL) { JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { - event_types.add(event_type); + event_types.add(linked(event_type)); } char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); + root[ESPHOME_F("device_class")] = json_table_str(obj->get_device_class_to(dc_buf)); this->add_sorting_info_(root, obj); } } @@ -2265,17 +2344,17 @@ void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson JsonObject root = builder.root(); + // Not linked: the main loop can rewrite update_info before the document is serialized + const auto &info = obj->update_info; set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version.c_str(), start_config); + info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("current_version")] = obj->update_info.current_version; - root[ESPHOME_F("title")] = obj->update_info.title; - // Truncate long changelogs — full text available via release_url + root[ESPHOME_F("current_version")] = info.current_version; + root[ESPHOME_F("title")] = info.title; + // Truncate long changelogs, the full text is available via release_url constexpr size_t max_summary_len = 256; - root[ESPHOME_F("summary")] = obj->update_info.summary.size() <= max_summary_len - ? obj->update_info.summary - : obj->update_info.summary.substr(0, max_summary_len); - root[ESPHOME_F("release_url")] = obj->update_info.release_url; + root[ESPHOME_F("summary")] = JsonString(info.summary.c_str(), std::min(info.summary.size(), max_summary_len)); + root[ESPHOME_F("release_url")] = info.release_url; this->add_sorting_info_(root, obj); } @@ -2610,7 +2689,7 @@ void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { - root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = linked(this->sorting_groups_[this->sorting_entitys_[entity].group_id].name); } } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 1f9b6cb934..d3bd1ae0da 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -4,6 +4,7 @@ #include "esphome/components/json/json_util.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" #include "esphome/core/entity_base.h" @@ -277,6 +278,14 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle an index request under '/'. void handle_index_request(AsyncWebServerRequest *request); +#ifdef USE_WEBSERVER_CAPTIVE + /// AP mode: DNS answers every name with the AP address and unknown URLs redirect to the interface. + /// wifi calls start before setup(), so it may touch nothing but dns_ (enable_loop is a no-op then). + void start_captive(); + void end_captive(); + bool is_captive() const { return this->dns_.is_running(); } +#endif + /// Return the webserver configuration as JSON. json::SerializationBuffer<> get_config_json(); @@ -606,6 +615,10 @@ class WebServer final : public Component, public AsyncWebHandler { #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif +#ifdef USE_WEBSERVER_CAPTIVE + void handle_not_found_(AsyncWebServerRequest *request); + web_server_base::CaptiveDNS dns_; +#endif #if USE_WEBSERVER_VERSION == 1 const char *css_url_{nullptr}; @@ -704,5 +717,9 @@ class WebServer final : public Component, public AsyncWebHandler { #endif }; +#ifdef USE_WEBSERVER_CAPTIVE +extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index c94eea1190..1625153749 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,9 @@ from pathlib import Path import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, PlatformFramework from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.helpers import copy_file_if_changed @@ -27,6 +28,22 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +def consume_captive_dns_sockets(config: ConfigType, name: str) -> None: + """Register the sockets a captive portal needs on top of the shared HTTP server: + 1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes, + which make several requests that linger in TIME_WAIT.""" + from esphome.components import socket + + socket.consume_sockets(3, name)(config) + socket.consume_sockets(1, name, socket.SocketType.UDP)(config) + + +def add_captive_dns_library() -> None: + """Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32.""" + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): + cg.add_library("DNSServer", None) + + def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType: """Register the shared listening socket for the HTTP server. @@ -82,3 +99,15 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + + +# The DNS server used for captive portals on ESP32; other platforms use the Arduino +# DNSServer library. Its source is also guarded by USE_CAPTIVE_PORTAL / USE_WEBSERVER_CAPTIVE. +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/web_server_base/captive_dns.h b/esphome/components/web_server_base/captive_dns.h new file mode 100644 index 0000000000..0d027d3c40 --- /dev/null +++ b/esphome/components/web_server_base/captive_dns.h @@ -0,0 +1,63 @@ +#pragma once +#include "esphome/core/defines.h" +// DNS server that answers every name with the access point address, so a phone joining the +// AP runs its captive portal check against the device. Shared by captive_portal and the +// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split. +#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE) +#include + +#include "esphome/components/network/ip_address.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" +#if defined(USE_ESP32) +#include "dns_server_esp32_idf.h" +#elif defined(USE_ARDUINO) +#include +#endif + +namespace esphome::web_server_base { + +// The server object only exists while running, so an idle owner (AP not up) pays one pointer. +class CaptiveDNS { + public: + /// Returns false when the DNS server could not start; nothing is kept, so is_running() stays false. + bool start(const network::IPAddress &ip) { + if (this->dns_server_ != nullptr) + return true; + auto server = make_unique(); +#if defined(USE_ESP32) + const bool started = server->start(ip); +#elif defined(USE_ARDUINO) + server->setErrorReplyCode(DNSReplyCode::NoError); + const bool started = server->start(53, ESPHOME_F("*"), ip); +#endif + if (!started) + return false; + this->dns_server_ = std::move(server); + return true; + } + void stop() { + if (this->dns_server_ == nullptr) + return; + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } + /// Answer one pending query; call from the owner's loop() while running. + void loop() { + if (this->dns_server_ == nullptr) + return; +#if defined(USE_ESP32) + this->dns_server_->process_next_request(); +#elif defined(USE_ARDUINO) + this->dns_server_->processNextRequest(); +#endif + } + bool is_running() const { return this->dns_server_ != nullptr; } + + protected: + // ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class. + std::unique_ptr dns_server_; +}; + +} // namespace esphome::web_server_base +#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/web_server_base/dns_server_esp32_idf.cpp similarity index 93% rename from esphome/components/captive_portal/dns_server_esp32_idf.cpp rename to esphome/components/web_server_base/dns_server_esp32_idf.cpp index d7a46373b8..c87433fa56 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/web_server_base/dns_server_esp32_idf.cpp @@ -1,5 +1,5 @@ #include "dns_server_esp32_idf.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -7,9 +7,9 @@ #include #include -namespace esphome::captive_portal { +namespace esphome::web_server_base { -ESPHOME_LOG_TAG(TAG, "captive_portal.dns"); +ESPHOME_LOG_TAG(TAG, "web_server_base.dns"); // DNS constants static constexpr uint16_t DNS_PORT = 53; @@ -46,7 +46,7 @@ struct DNSAnswer { uint32_t ip_addr; } __attribute__((packed)); -void DNSServer::start(const network::IPAddress &ip) { +bool DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; @@ -57,7 +57,7 @@ void DNSServer::start(const network::IPAddress &ip) { this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP).release(); if (this->socket_ == nullptr) { ESP_LOGE(TAG, "Socket create failed"); - return; + return false; } // Set socket options @@ -72,9 +72,10 @@ void DNSServer::start(const network::IPAddress &ip) { if (err != 0) { ESP_LOGE(TAG, "Bind failed: %d", errno); this->destroy_socket_(); - return; + return false; } ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); + return true; } void DNSServer::stop() { @@ -202,6 +203,6 @@ void DNSServer::process_next_request() { } } -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/web_server_base/dns_server_esp32_idf.h similarity index 58% rename from esphome/components/captive_portal/dns_server_esp32_idf.h rename to esphome/components/web_server_base/dns_server_esp32_idf.h index b30856c204..2b10af332a 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/web_server_base/dns_server_esp32_idf.h @@ -1,15 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// ESP32 backend for CaptiveDNS: answers every A query with the access point address. +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/socket/socket.h" -namespace esphome::captive_portal { +namespace esphome::web_server_base { class DNSServer { public: - void start(const network::IPAddress &ip); + /// Returns false when the socket could not be created or bound. + bool start(const network::IPAddress &ip); void stop(); void process_next_request(); @@ -27,6 +30,6 @@ class DNSServer { uint8_t buffer_[DNS_BUFFER_SIZE]; }; -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9a6e419e43..d9a7d65e8c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -311,6 +311,10 @@ esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_t } AsyncWebServerRequest::~AsyncWebServerRequest() { +#ifdef USE_WEBSERVER_OTA + if (this->on_disconnect_) + this->on_disconnect_(); +#endif delete this->rsp_; for (auto *param : this->params_) { delete param; // NOLINT(cppcoreguidelines-owning-memory) @@ -335,9 +339,9 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -void AsyncWebServerRequest::redirect(const std::string &url) { +void AsyncWebServerRequest::redirect(const char *url) { httpd_resp_set_status(*this, "302 Found"); - httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Location", url); httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } @@ -850,7 +854,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; + root["name"] = json::linked(group.second.name); root["sorting_weight"] = group.second.weight; message = builder.serialize(); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 581ba8323b..4d4490d907 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,8 @@ class AsyncWebServerRequest { void requestAuthentication() const; #endif - void redirect(const std::string &url); + void redirect(const char *url); + void redirect(const std::string &url) { this->redirect(url.c_str()); } inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); @@ -182,6 +183,12 @@ class AsyncWebServerRequest { std::string arg(const char *name); std::string arg(const std::string &name) { return this->arg(name.c_str()); } +#ifdef USE_WEBSERVER_OTA + /// Called when the request ends, including early returns. + // NOLINTNEXTLINE(readability-identifier-naming) + void onDisconnect(std::function fn) { this->on_disconnect_ = std::move(fn); } +#endif + operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; // NOLINTNEXTLINE(readability-identifier-naming) @@ -197,6 +204,9 @@ class AsyncWebServerRequest { optional find_query_value_(const char *name) const; std::vector params_; std::string post_query_; +#ifdef USE_WEBSERVER_OTA + std::function on_disconnect_; +#endif AsyncWebServerRequest(httpd_req_t *req) : req_(req) {} AsyncWebServerRequest(httpd_req_t *req, std::string post_query) : req_(req), post_query_(std::move(post_query)) {} void init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type); diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 2576856e2f..938b191756 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -254,7 +254,7 @@ size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { void WeikaiChannel::setup_channel() { ESP_LOGCONFIG(TAG, " Setting up UART %s:%s", this->parent_->get_name(), this->get_channel_name()); // we enable transmit and receive on this channel - if (this->check_channel_down()) { + if (this->check_channel_down_()) { ESP_LOGCONFIG(TAG, " Error channel %s not working", this->get_channel_name()); } this->reset_fifo_(); @@ -378,7 +378,7 @@ size_t WeikaiChannel::rx_in_fifo_() { return available; } -bool WeikaiChannel::check_channel_down() { +bool WeikaiChannel::check_channel_down_() { // to check if we channel is up we write to the LCR W/R register // note that this will put a break on the tx line for few ms WeikaiRegister &lcr = this->reg(WKREG_LCR); diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 02a39d3c84..eb8a41ad9d 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -266,7 +266,7 @@ class WeikaiComponent : public Component { /////////////////////////////////////////////////////////////////////////////// /// @brief Helper class to expose a WeiKai family IO pin as an internal GPIO pin. /////////////////////////////////////////////////////////////////////////////// -class WeikaiGPIOPin : public GPIOPin { +class WeikaiGPIOPin final : public GPIOPin { public: void set_parent(WeikaiComponent *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } @@ -293,7 +293,7 @@ class WeikaiGPIOPin : public GPIOPin { /// uart::UARTComponent virtual class. This class is common to the different members of the Weikai /// components family and therefore avoid code duplication. /////////////////////////////////////////////////////////////////////////////////////////////////// -class WeikaiChannel : public uart::UARTComponent { +class WeikaiChannel final : public uart::UARTComponent { public: /// @brief We belongs to this WeikaiComponent /// @param parent pointer to the component we belongs to @@ -315,10 +315,10 @@ class WeikaiChannel : public uart::UARTComponent { const char *get_channel_name() { return this->name_.c_str(); } /// @brief Setup the channel - void virtual setup_channel(); + void setup_channel(); /// @brief dump channel information - void virtual dump_channel(); + void dump_channel(); /// @brief Factory method to create a WeikaiRegister proxy object /// @param reg address of the register @@ -423,7 +423,7 @@ class WeikaiChannel : public uart::UARTComponent { /// @brief check if channel is alive /// @return true if OK - bool virtual check_channel_down(); + bool check_channel_down_(); #ifdef TEST_COMPONENT /// @ingroup test_ diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index 3435a6c0bf..efc7e6ca57 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -5,7 +5,7 @@ from esphome.const import CONF_MODEL from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] -CODEOWNERS = ["@glmnet"] +CODEOWNERS = ["@glmnet", "@arpena"] whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) @@ -14,6 +14,7 @@ Model = whirlpool_ns.enum("Model") MODELS = { "DG11J1-3A": Model.MODEL_DG11J1_3A, "DG11J1-91": Model.MODEL_DG11J1_91, + "HISENSE_ARG": Model.MODEL_HISENSE_ARG, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( @@ -26,3 +27,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) + # cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared + if config[CONF_MODEL] == "HISENSE_ARG": + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 73fab843fc..e341ce04bb 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -27,10 +27,20 @@ const uint8_t WHIRLPOOL_FAN_HIGH = 1; const uint8_t WHIRLPOOL_FAN_MED = 2; const uint8_t WHIRLPOOL_FAN_LOW = 3; -const uint8_t WHIRLPOOL_SWING_MASK = 128; +const uint8_t WHIRLPOOL_FAN_MASK = 0x03; + +const uint8_t WHIRLPOOL_SWING_MASK = 128; // byte 2 +const uint8_t WHIRLPOOL_SWING_BYTE8 = 0x40; // byte 8 const uint8_t WHIRLPOOL_POWER = 0x04; +// Hisense ARG model; unused constexpr values cost nothing in DG11J1-only builds +static constexpr uint8_t HISENSE_ARG_DISPLAY_ON = 0x80; // byte 11 +static constexpr uint8_t HISENSE_ARG_AMBIENT_TEMP = 0x18; // byte 12, 24 C +static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15 +static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15 +static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C + WhirlpoolClimate::WhirlpoolClimate() : climate_ir::ClimateIR( WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true, @@ -44,11 +54,11 @@ void WhirlpoolClimate::transmit_state() { remote_state[0] = 0x83; remote_state[1] = 0x06; remote_state[6] = 0x80; - // MODEL DG11J191 - remote_state[18] = 0x08; + remote_state[18] = 0x08; // the same on every supported model auto powered_on = this->mode != climate::CLIMATE_MODE_OFF; - if (powered_on != this->powered_on_assumed) { + const bool power_toggle = powered_on != this->powered_on_assumed; + if (power_toggle) { // Set power toggle command remote_state[2] = 4; remote_state[15] = 1; @@ -83,9 +93,16 @@ void WhirlpoolClimate::transmit_state() { break; } + const bool hisense_arg = this->is_hisense_arg_(); + // Temperature auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_())); - remote_state[3] |= (uint8_t) (temp - this->temperature_min_()) << 4; + const auto temp_step = (uint8_t) (temp - this->temperature_min_()); + if (hisense_arg && this->fixed_temp_mode_()) { + remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4; + } else { + remote_state[3] |= temp_step << 4; + } // Fan speed switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { @@ -104,10 +121,17 @@ void WhirlpoolClimate::transmit_state() { // Swing ESP_LOGV(TAG, "send swing %s", this->send_swing_cmd_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false")); - if (this->send_swing_cmd_) { + if (hisense_arg) { + // This model sends swing as a state on every frame; the DG11J1 remotes send a toggle + if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) { + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] = WHIRLPOOL_SWING_BYTE8; + } + this->encode_hisense_arg_(remote_state, power_toggle, temp_step); + } else if (this->send_swing_cmd_) { if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) { - remote_state[2] |= 128; - remote_state[8] |= 64; + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] |= WHIRLPOOL_SWING_BYTE8; } } @@ -155,6 +179,49 @@ void WhirlpoolClimate::transmit_state() { transmit.perform(); } +bool WhirlpoolClimate::fixed_temp_mode_() const { + return this->mode == climate::CLIMATE_MODE_HEAT_COOL || this->mode == climate::CLIMATE_MODE_FAN_ONLY; +} + +void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + remote_state[11] = HISENSE_ARG_DISPLAY_ON; + remote_state[12] = HISENSE_ARG_AMBIENT_TEMP; + remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY; + + switch (this->mode) { + case climate::CLIMATE_MODE_OFF: + // The remote keeps the cool code in the power-off frame + remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL; + break; + case climate::CLIMATE_MODE_DRY: + // The unit runs these fan speeds itself; the user's choice is kept for the next mode change + remote_state[2] &= ~WHIRLPOOL_FAN_MASK; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + if ((remote_state[2] & WHIRLPOOL_FAN_MASK) == WHIRLPOOL_FAN_AUTO) { + remote_state[2] |= WHIRLPOOL_FAN_MED; + } + break; + case climate::CLIMATE_MODE_HEAT_COOL: + // Auto sends the target as one of four range codes in byte 2 bits 6:4 instead of the temperature nibble. + // Taken from the HPA-4911 firmware: 16 to 21 C, 22 C, 24 C, and everything else (23 C and 25 C up) share a code. + if (temp_step < 6) { + remote_state[2] |= 0x30; + } else if (temp_step == 6) { + remote_state[2] |= 0x50; + } else if (temp_step == 8) { + remote_state[2] |= 0x40; + } else { + remote_state[2] |= 0x20; + } + break; + default: + break; + } +#endif +} + bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { // Check if the esp isn't currently transmitting. if (millis() - this->last_transmit_time_ < 500) { @@ -265,16 +332,19 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } } - // Set received temp - int temp = remote_state[3] & 0xF0; - ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); - temp = (uint8_t) temp >> 4; - temp += static_cast(this->temperature_min_()); - ESP_LOGVV(TAG, "Temperature Climate: %u", temp); - this->target_temperature = temp; + // Set received temp; Hisense ARG sends fixed values in auto and fan-only + const bool hisense_arg = this->is_hisense_arg_(); + if (!hisense_arg || !this->fixed_temp_mode_()) { + int temp = remote_state[3] & 0xF0; + ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); + temp = (uint8_t) temp >> 4; + temp += static_cast(this->temperature_min_()); + ESP_LOGVV(TAG, "Temperature Climate: %u", temp); + this->target_temperature = temp; + } // Set received fan speed - auto fan = remote_state[2] & 0x03; + auto fan = remote_state[2] & WHIRLPOOL_FAN_MASK; ESP_LOGVV(TAG, "Fan: %02X", fan); switch (fan) { case WHIRLPOOL_FAN_HIGH: @@ -293,7 +363,11 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } // Set received swing status - if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && remote_state[8] == 0x40) { + if (hisense_arg) { + this->swing_mode = + (remote_state[8] & WHIRLPOOL_SWING_BYTE8) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + } else if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && + remote_state[8] == WHIRLPOOL_SWING_BYTE8) { ESP_LOGVV(TAG, "Swing toggle pressed "); if (this->swing_mode == climate::CLIMATE_SWING_OFF) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index b705ee95fa..8b5fc7519a 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -6,8 +6,9 @@ namespace esphome::whirlpool { /// Simple enum to represent models. enum Model { - MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 - MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 + MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30 }; // Temperature @@ -46,10 +47,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR { void transmit_state() override; /// Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; + /// False when the model is compiled out, so the compiler drops the Hisense ARG branches. + bool is_hisense_arg_() const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + return this->model_ == MODEL_HISENSE_ARG; +#else + return false; +#endif + } + /// Modes in which the Hisense ARG model sends a fixed temperature. + bool fixed_temp_mode_() const; + /// Add the Hisense ARG bytes on top of the common whirlpool frame. + void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const; /// Set the time of the last transmission. uint32_t last_transmit_time_{}; - bool send_swing_cmd_{false}; + bool send_swing_cmd_{false}; // the DG11J1 remotes send swing as a toggle, only on the call that changed it Model model_; float temperature_min_() { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index aa6d113e16..667e288fd7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( CONF_USE_ADDRESS, CONF_USERNAME, PLACEHOLDER_WIFI_SSID, + Framework, Platform, PlatformFramework, ) @@ -344,6 +345,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -490,6 +497,38 @@ def _fast_connect_schema(value: Any) -> ConfigType: return FAST_CONNECT_SCHEMA(value) +CONF_LEASE_CACHE = "lease_cache" +CONF_MAX_AGE = "max_age" + +LEASE_CACHE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + # Defaults to RTC in _lease_cache_schema; flash wears and must be opted into. + cv.Optional(CONF_STORAGE): preferences.validate_storage, + cv.Optional(CONF_MAX_AGE): cv.positive_time_period_seconds, + } +) + + +def _lease_cache_schema(value: Any) -> ConfigType: + """Accept a plain boolean shorthand or a dict with enabled/storage/max_age keys.""" + if not isinstance(value, dict): + value = {CONF_ENABLED: value} + value = LEASE_CACHE_SCHEMA(value) + if value[CONF_ENABLED]: + cv.only_on_esp32(value) + cv.only_with_framework(Framework.ESP_IDF)(value) + if CONF_STORAGE not in value: + if not preferences.rtc_supported(): + raise cv.Invalid( + "This ESP32 variant has no RTC memory; set 'storage: flash' to " + "cache the lease in flash", + path=[CONF_STORAGE], + ) + value[CONF_STORAGE] = preferences.STORAGE_RTC + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -518,10 +557,12 @@ CONFIG_SCHEMA = cv.All( ln882x="none", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, + cv.Optional(CONF_LEASE_CACHE, default=False): _lease_cache_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) @@ -723,6 +764,14 @@ async def to_code(config): # used flash (the in_flash flag was previously ignored outside ESP8266). if preferences.is_in_flash(fast_connect[CONF_STORAGE]): cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") + lease_cache = config[CONF_LEASE_CACHE] + if lease_cache[CONF_ENABLED]: + cg.add_define("USE_WIFI_LEASE_CACHE") + # is_in_flash() also emits USE_ESP32_RTC_PREFERENCES when rtc is chosen. + if preferences.is_in_flash(lease_cache[CONF_STORAGE]): + cg.add_define("USE_WIFI_LEASE_CACHE_IN_FLASH") + if (max_age := lease_cache.get(CONF_MAX_AGE)) is not None: + cg.add(var.set_lease_cache_max_age(int(max_age.total_seconds))) # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 001dae98b8..62732e0915 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -36,6 +36,9 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/web_server/web_server.h" +#endif #ifdef USE_IMPROV_BLE #include "esphome/components/improv_ble/improv_ble_component.h" @@ -398,7 +401,6 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; -static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -654,12 +656,12 @@ void WiFiComponent::setup() { #endif #if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) - // The access point is a provisioning surface: once the provisioning window has - // closed, shut it down (mirrors the teardown done on a successful connection). - // The captive portal registers its own closed-callback, and the fallback block - // in loop() is gated so neither is started again afterwards. + // The access point and the portals on it are provisioning surfaces: once the window has + // closed, end them (mirrors the teardown on a successful connection). The fallback block in + // loop() is gated so none of them is started again afterwards. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); #ifdef USE_WIFI_AP_EXCLUSIVE @@ -702,6 +704,15 @@ void WiFiComponent::start() { this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif +#ifdef USE_WIFI_LEASE_CACHE +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + const bool lease_cache_in_flash = true; +#else + const bool lease_cache_in_flash = false; +#endif + this->lease_cache_pref_ = + global_preferences->make_preference(hash + 2, lease_cache_in_flash); +#endif SavedWifiSettings save{}; if (this->pref_.load(&save)) { @@ -712,6 +723,10 @@ void WiFiComponent::start() { sta.set_password(save.password); this->set_sta(sta); } +#ifdef USE_WIFI_LEASE_CACHE + // After the saved credentials: set_sta() clears the cache, and the lease belongs to that same network + this->load_lease_settings_(); +#endif if (this->has_sta()) { this->wifi_sta_pre_setup_(); @@ -766,15 +781,13 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { - // Where the radio scans alongside the AP, the portal can list networks. - if (!WIFI_AP_EXCLUSIVE) { - this->wifi_sta_pre_setup_(); - this->start_scanning(); - } - captive_portal::global_captive_portal->start(); + // Where the radio scans alongside the AP, the portal can list networks. + if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) { + this->wifi_sta_pre_setup_(); + this->start_scanning(); } #endif + this->start_ap_portal_(); #endif // USE_WIFI_AP } #ifdef USE_IMPROV_BLE @@ -835,8 +848,8 @@ void WiFiComponent::loop() { this->check_connecting_finished(now); break; } - // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); + // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP + bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -932,16 +945,15 @@ void WiFiComponent::loop() { this->ap_exclusive_changed_ = now; #endif this->setup_ap_config_(); + // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. + if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL - // Where the AP runs on its own, a portal with no AP behind it would - // only stretch the cooldowns. - if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; - captive_portal::global_captive_portal->start(); - } #endif + this->start_ap_portal_(); + } } } #endif // USE_WIFI_AP @@ -1036,30 +1048,17 @@ void WiFiComponent::setup_ap_config_() { return; if (this->ap_.ssid_.empty()) { - // Build AP SSID from app name without heap allocation - // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7 + // Build AP SSID from app name without heap allocation; WiFi SSID max is 32 bytes static constexpr size_t AP_SSID_MAX_LEN = 32; - static constexpr size_t AP_SSID_PREFIX_LEN = 25; - static constexpr size_t AP_SSID_SUFFIX_LEN = 7; const auto &app_name = App.get_name(); - const char *name_ptr = app_name.c_str(); - size_t name_len = app_name.length(); - - if (name_len <= AP_SSID_MAX_LEN) { + if (app_name.length() <= AP_SSID_MAX_LEN) { // Name fits, use directly - this->ap_.set_ssid(name_ptr); + this->ap_.set_ssid(app_name.c_str()); } else { - // Name too long, need to truncate into stack buffer char ssid_buf[AP_SSID_MAX_LEN + 1]; - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 25 chars and last 7 chars (MAC suffix), remove middle - memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN); - memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN); - } else { - memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN); - } - ssid_buf[AP_SSID_MAX_LEN] = '\0'; + truncate_name_to(ssid_buf, sizeof(ssid_buf), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); this->ap_.set_ssid(ssid_buf); } } @@ -1119,11 +1118,7 @@ void WiFiComponent::pause_exclusive_ap_() { #endif void WiFiComponent::disable_ap_() { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif + this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } @@ -1149,6 +1144,10 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->selected_sta_index_ = 0; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; +#ifdef USE_WIFI_LEASE_CACHE + // Index 0 is a different network now, so its cached lease must not be re-applied + this->clear_lease_settings_(); +#endif } WiFiAP WiFiComponent::build_params_for_current_phase_() { @@ -1448,6 +1447,9 @@ void WiFiComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = WIFI_COMPONENT_STATE_DISABLED; +#ifdef USE_WIFI_LEASE_CACHE + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif this->wifi_disconnect_(); this->wifi_mode_(false, false); } @@ -2067,10 +2069,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { break; case WiFiRetryPhase::RESTARTING_ADAPTER: - // Skip actual adapter restart if captive portal/improv is active + // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan - // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { + // without disrupting the portal/improv connection + if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2329,6 +2331,36 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } + +bool WiFiComponent::is_ap_portal_active_() { +#ifdef USE_WEBSERVER_CAPTIVE + if (web_server::global_web_server->is_captive()) + return true; +#endif + return this->is_captive_portal_active_(); +} + +// global_web_server needs no null check: codegen always instantiates WebServer when +// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. +void WiFiComponent::start_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (captive_portal::global_captive_portal != nullptr) + captive_portal::global_captive_portal->start(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->start_captive(); +#endif +} + +void WiFiComponent::end_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) + captive_portal::global_captive_portal->end(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->end_captive(); +#endif +} bool WiFiComponent::is_improv_ble_active_() { #ifdef USE_IMPROV_BLE return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); @@ -2440,6 +2472,26 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::load_lease_settings_() { + if (!this->lease_cache_pref_.load(&this->cached_lease_) || this->cached_lease_.version != SAVED_WIFI_LEASE_VERSION) { + this->cached_lease_ = {}; + return; + } + const uint8_t *ip = reinterpret_cast(&this->cached_lease_.ip); // network byte order + ESP_LOGD(TAG, "Loaded cached lease %u.%u.%u.%u (network %d, lease %us)", ip[0], ip[1], ip[2], ip[3], + this->cached_lease_.ap_index, this->cached_lease_.lease_secs); +} + +void WiFiComponent::clear_lease_settings_() { + if (this->cached_lease_.version == 0) + return; + this->cached_lease_ = {}; + this->lease_cache_pref_.save(&this->cached_lease_); + ESP_LOGD(TAG, "Lease cache: cleared"); +} +#endif + WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9700e36e6f..c1f4061a53 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -86,6 +86,25 @@ struct SavedWifiFastConnectSettings { int8_t ap_index; } PACKED; // NOLINT +#ifdef USE_WIFI_LEASE_CACHE +// Checked on load; a zeroed record (version 0) means no cached lease. +static constexpr uint8_t SAVED_WIFI_LEASE_VERSION = 1; + +// Cached DHCP lease. IPv4 fields in network byte order, times in seconds. +struct SavedWifiLeaseSettings { + uint8_t version; + int8_t ap_index; + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + uint32_t dns1; + uint32_t dns2; + uint32_t lease_secs; + uint32_t t1_renew; + uint32_t saved_at; // RTC-domain clock at capture +} PACKED; // NOLINT +#endif + enum WiFiComponentState : uint8_t { /** Nothing has been initialized yet. Internal AP, if configured, is disabled at this point. */ WIFI_COMPONENT_STATE_OFF = 0, @@ -429,6 +448,10 @@ class WiFiPowerSaveListener { virtual void on_wifi_power_save(WiFiPowerSaveMode mode) = 0; }; +// Scheduler ids +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; +static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent final : public Component { public: @@ -565,6 +588,9 @@ class WiFiComponent final : public Component { int8_t wifi_rssi(); void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } +#ifdef USE_WIFI_LEASE_CACHE + void set_lease_cache_max_age(uint32_t seconds) { this->lease_cache_max_age_ = seconds; } +#endif void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } @@ -687,7 +713,7 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); - /// End the captive portal and turn the AP off. + /// End the portals and turn the AP off. void disable_ap_(); #ifdef USE_WIFI_AP_EXCLUSIVE /// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout. @@ -806,6 +832,10 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); + /// captive_portal or the web_server AP mode is serving a user on the access point + bool is_ap_portal_active_(); + void start_ap_portal_(); + void end_ap_portal_(); bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT @@ -813,6 +843,15 @@ class WiFiComponent final : public Component { void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif +#ifdef USE_WIFI_LEASE_CACHE + void load_lease_settings_(); + void save_lease_settings_(); + void clear_lease_settings_(); + // Cached lease belongs to the selected network and is still provably valid. + bool lease_valid_for_apply_() const; + void renew_cached_lease_(); +#endif + // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); @@ -899,6 +938,13 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; #endif +#ifdef USE_WIFI_LEASE_CACHE + ESPPreferenceObject lease_cache_pref_; + SavedWifiLeaseSettings cached_lease_{}; // version 0 = none + // Current IP came from the cache (DHCP not run); suppresses saving until a real lease arrives. + bool applied_cached_lease_{false}; + uint32_t lease_cache_max_age_{0}; // 0 = no cap +#endif #ifdef USE_WIFI_CONNECT_TRIGGER Trigger<> connect_trigger_; #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 3769e26831..bdca350dde 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -35,6 +35,12 @@ #include "lwip/apps/sntp.h" #include "lwip/dns.h" #include "lwip/err.h" +#ifdef USE_WIFI_LEASE_CACHE +#include // esp_netif_get_netif_impl() +#include // esp_rtc_get_time_us() -- RTC-domain clock for lease age +#include "lwip/dhcp.h" +#include "lwip/netif.h" +#endif #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -553,6 +559,68 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return true; } +// sntp_servermode_dhcp() is an empty macro unless lwIP takes NTP servers from DHCP; that build needs the core lock +static void disable_dhcp_sntp() { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); +} + +#ifdef USE_WIFI_LEASE_CACHE +// Minimum lease time that must remain before a cached lease is applied. +static constexpr uint32_t LEASE_APPLY_MARGIN_SECS = 60; + +// Seconds since capture on the RTC-domain clock; UINT32_MAX if the clock was reset (age unknown). +static uint32_t lease_cache_age_secs(const SavedWifiLeaseSettings &lease) { + uint32_t now = static_cast(esp_rtc_get_time_us() / 1000000ULL); + return now >= lease.saved_at ? now - lease.saved_at : UINT32_MAX; +} + +// Renewal deadline (T1) from capture; half the lease when T1 is missing or invalid (RFC 2131). +static uint32_t lease_renew_at_secs(const SavedWifiLeaseSettings &lease) { + uint32_t renew_at = lease.t1_renew; + if (renew_at == 0 || renew_at >= lease.lease_secs) + renew_at = lease.lease_secs / 2; + return renew_at; +} + +// Starting the DHCP client drops the static address and DNS servers, so open connections reset +void WiFiComponent::renew_cached_lease_() { + ESP_LOGI(TAG, "Cached lease renew deadline reached; re-acquiring the address over DHCP, connections may drop"); + this->applied_cached_lease_ = false; + // Same SNTP leak guard as the DHCP path: https://github.com/esphome/issues/issues/2299 + disable_dhcp_sntp(); + esp_err_t err = esp_netif_dhcpc_start(s_sta_netif); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Lease renew: starting DHCP client failed: %s, retrying in 60 s", esp_err_to_name(err)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, 60000, [this]() { this->renew_cached_lease_(); }); + } +} + +bool WiFiComponent::lease_valid_for_apply_() const { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + if (lease.version != SAVED_WIFI_LEASE_VERSION || this->selected_sta_index_ < 0 || + lease.ap_index != this->selected_sta_index_) + return false; + // The age is only known when the RTC clock survived the reset (deep sleep or software reboot). + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP && reason != ESP_RST_SW) + return false; + uint32_t age = lease_cache_age_secs(lease); + if (age == UINT32_MAX) + return false; + if (this->lease_cache_max_age_ != 0 && age > this->lease_cache_max_age_) + return false; + // Past T1 the lease needs DHCP renewal anyway. + if (age >= lease_renew_at_secs(lease)) + return false; + // Scale the margin with the lease: the RTC RC oscillator drifts a few percent. + uint32_t margin = std::max(LEASE_APPLY_MARGIN_SECS, lease.lease_secs / 16); + return lease.lease_secs > age + margin; +} +#endif + bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) @@ -571,18 +639,43 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return false; } - if (!manual_ip.has_value()) { +#ifdef USE_WIFI_LEASE_CACHE + // Without a manual IP, a still-valid cached lease is applied as a static IP to skip DHCP. + optional effective_ip = manual_ip; + this->applied_cached_lease_ = false; + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); + if (!effective_ip.has_value() && this->lease_valid_for_apply_()) { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + esp_ip4_addr_t ip4{lease.ip}, mask4{lease.netmask}, gw4{lease.gateway}, dns1_4{lease.dns1}, dns2_4{lease.dns2}; + ManualIP cached; + cached.static_ip = network::IPAddress(&ip4); + cached.gateway = network::IPAddress(&gw4); + cached.subnet = network::IPAddress(&mask4); + cached.dns1 = network::IPAddress(&dns1_4); + cached.dns2 = network::IPAddress(&dns2_4); + effective_ip = cached; + this->applied_cached_lease_ = true; + + uint32_t age = lease_cache_age_secs(lease); + uint32_t remaining = lease.lease_secs > age ? lease.lease_secs - age : 0; + ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%us left, skipping DHCP", IP2STR(&ip4), remaining); + + // Still awake at T1: switch to real DHCP. lease_valid_for_apply_() guarantees age < T1. + uint32_t renew_in = lease_renew_at_secs(lease) - age; + // Clamp below UINT32_MAX, which the scheduler treats as "don't run". + uint32_t renew_ms = + static_cast(std::min(static_cast(renew_in) * 1000ULL, UINT32_MAX - 1)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, renew_ms, [this]() { this->renew_cached_lease_(); }); + } +#else + const optional &effective_ip = manual_ip; +#endif + + if (!effective_ip.has_value()) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - { -#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 - // sntp_servermode_dhcp() is an empty macro unless lwIP is built with - // DHCP-supplied NTP servers, so only that build needs the core lock. - LwIPLock lock; -#endif - sntp_servermode_dhcp(false); - } + disable_dhcp_sntp(); // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { @@ -596,9 +689,9 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_ip_info_t info; // struct of ip4_addr_t with ip, netmask, gw - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; + info.ip = effective_ip->static_ip; + info.gw = effective_ip->gateway; + info.netmask = effective_ip->subnet; err = esp_netif_dhcpc_stop(s_sta_netif); if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { ESP_LOGV(TAG, "Stopping DHCP client failed: %s", esp_err_to_name(err)); @@ -610,18 +703,82 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_dns_info_t dns; - if (manual_ip->dns1.is_set()) { - dns.ip = manual_ip->dns1; + if (effective_ip->dns1.is_set()) { + dns.ip = effective_ip->dns1; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns); } - if (manual_ip->dns2.is_set()) { - dns.ip = manual_ip->dns2; + if (effective_ip->dns2.is_set()) { + dns.ip = effective_ip->dns2; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns); } return true; } +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::save_lease_settings_() { + // A cached lease applied as a static IP has no DHCP timing to capture. + if (s_sta_netif == nullptr || this->applied_cached_lease_) + return; + +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) + return; +#endif + + esp_netif_ip_info_t info; + if (esp_netif_get_ip_info(s_sta_netif, &info) != ESP_OK || info.ip.addr == 0) + return; + + SavedWifiLeaseSettings lease{}; + lease.version = SAVED_WIFI_LEASE_VERSION; + lease.ap_index = this->selected_sta_index_; + lease.ip = info.ip.addr; + lease.netmask = info.netmask.addr; + lease.gateway = info.gw.addr; + + esp_netif_dns_info_t dns; + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns1 = dns.ip.u_addr.ip4.addr; + } + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns2 = dns.ip.u_addr.ip4.addr; + } + + // Lease timing is only in lwIP's internal DHCP state; without it the lease can't be aged. + bool have_timing = false; + if (auto *impl = static_cast(esp_netif_get_netif_impl(s_sta_netif))) { + LwIPLock lock; + if (struct dhcp *d = netif_dhcp_data(impl)) { + lease.lease_secs = d->offered_t0_lease; + lease.t1_renew = d->offered_t1_renew; + have_timing = true; + } + } + if (!have_timing) + return; + + lease.saved_at = static_cast(esp_rtc_get_time_us() / 1000000ULL); + + // On flash, skip a write that would only bump saved_at (an older one only overstates the age), + // unless it is from a previous RTC clock period (power cycle). +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + constexpr size_t compare_len = offsetof(SavedWifiLeaseSettings, saved_at); +#else + constexpr size_t compare_len = sizeof(SavedWifiLeaseSettings); +#endif + const SavedWifiLeaseSettings &previous = this->cached_lease_; + if (previous.saved_at <= lease.saved_at && memcmp(&previous, &lease, compare_len) == 0) + return; + + this->lease_cache_pref_.save(&lease); + this->cached_lease_ = lease; + + ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %us)", IP2STR(&info.ip), lease.ap_index, + lease.lease_secs); +} +#endif + esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { @@ -853,9 +1010,17 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { // This ensures wifi.connected condition returns true in listener automations this->pending_.connect_state = true; #endif - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + // For static IP configurations (manual or cached lease), GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_WIFI_IP_STATE_LISTENERS) && (defined(USE_WIFI_MANUAL_IP) || defined(USE_WIFI_LEASE_CACHE)) + bool static_ip = false; +#ifdef USE_WIFI_MANUAL_IP + const WiFiAP *config = this->get_selected_sta_(); + static_ip = config && config->get_manual_ip().has_value(); +#endif +#ifdef USE_WIFI_LEASE_CACHE + static_ip = static_ip || this->applied_cached_lease_; +#endif + if (static_ip) { this->notify_ip_state_listeners_(); } #endif @@ -889,6 +1054,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#ifdef USE_WIFI_LEASE_CACHE + this->save_lease_settings_(); // events are drained on the main loop +#endif #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif @@ -1166,21 +1334,18 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) - // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled - // This provides a standards-compliant way for clients to discover the captive portal - if (captive_portal::global_captive_portal != nullptr) { - // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy - static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null - memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates - network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); - err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, - strlen(captive_portal_uri)); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); - } else { - ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); - } +#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // DHCP option 114 (captive portal URI) lets clients find the portal; either define means one exists + // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy + static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null + memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates + network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); + err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, + strlen(captive_portal_uri)); + if (err != ESP_OK) { + ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); + } else { + ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); } #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 2a9e682440..ca32f3d3ff 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -252,9 +252,14 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; - WiFiStatus status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), - ap.get_channel(), // 0 = auto - ap.has_bssid() ? ap.get_bssid().data() : NULL); + WiFiStatus status; + { + // The driver blocks until the join has started, or finished on Realtek + UnavoidableBlockingScope scope; + status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), + ap.get_channel(), // 0 = auto + ap.has_bssid() ? ap.get_bssid().data() : NULL); + } if (status != WL_CONNECTED) { ESP_LOGW(TAG, "WiFi.begin failed: %d", status); // Without this reset the state machine stays at CONNECTING and each retry diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index dd6ecd3b20..c890fba3d7 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -1,6 +1,8 @@ #ifdef USE_ZEPHYR #include "ble_server.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -13,13 +15,8 @@ static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-globa BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#define DEVICE_NAME CONFIG_BT_DEVICE_NAME -#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) - -static const bt_data AD[] = { - BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), - BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), -}; +// What the advertisement has left for the name: the flags take 3 bytes and the name header 2 +static constexpr size_t MAX_ADV_NAME_LEN = BT_GAP_ADV_MAX_ADV_DATA_LEN - 3 - 2; static const bt_data SD[] = { #ifdef USE_OTA @@ -36,7 +33,19 @@ static void advertise(k_work *work) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); } - rc = bt_le_adv_start(ADV_PARAM, AD, ARRAY_SIZE(AD), SD, ARRAY_SIZE(SD)); + // Zephyr would cut the end of a long name, which is where the MAC suffix is + char name[MAX_ADV_NAME_LEN + 1]; + const auto &app_name = App.get_name(); + const size_t name_len = + truncate_name_to(name, sizeof(name), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); + const uint8_t name_type = name_len < app_name.length() ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE; + const bt_data ad[] = { + BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(name_type, name, static_cast(name_len)), + }; + + rc = bt_le_adv_start(ADV_PARAM, ad, ARRAY_SIZE(ad), SD, ARRAY_SIZE(SD)); if (rc) { ESP_LOGE(TAG, "Advertising failed to start (rc %d)", rc); return; diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 64d22f9a14..e7d2501e5d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -114,7 +114,13 @@ from esphome.schema_extractors import ( # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import KeyInvalid, _Schema -from esphome.yaml_util import SensitiveStr, make_data_base +from esphome.yaml_util import ( + Sensitive, + SensitiveHexInt, + SensitiveInt, + SensitiveStr, + make_data_base, +) if typing.TYPE_CHECKING: from esphome.types import ConfigType @@ -562,11 +568,17 @@ class SensitiveValidator: def __call__(self, value: typing.Any) -> typing.Any: validated = self.inner(value) - # Tag string results so yaml_util.dump can mask them. Non-string - # results pass through unchanged; already-tagged values are not - # re-wrapped to keep nested cv.sensitive applications idempotent. - if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + # Already-tagged values are returned as-is so nested cv.sensitive is + # idempotent; bool is an int subclass and must stay bool. Other types + # (e.g. lambdas) pass through unmasked. + if isinstance(validated, (Sensitive, bool)): + return validated + if isinstance(validated, str): return SensitiveStr(validated) + if isinstance(validated, int): + return (SensitiveHexInt if isinstance(validated, HexInt) else SensitiveInt)( + validated + ) return validated def __repr__(self) -> str: diff --git a/esphome/core/application.h b/esphome/core/application.h index b91bc187e6..f9f98a6707 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -390,6 +390,7 @@ class Application { friend Component; friend class Scheduler; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -632,6 +633,38 @@ class LoopBlockingGuard { static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); }; +/// Leaves a stretch of the current loop pass out of the blocking warning. +/// +/// Only for work that cannot be made shorter or split across passes: radio +/// bring-up, a first network connect, a key generation. Work that could be +/// cached, deferred, polled or turned into a state machine stays outside, so +/// the warning keeps finding it. +/// +/// Affects only work timed by LoopBlockingGuard: loop() and scheduler +/// callbacks, not setup(). Main loop task only. The watchdog is not fed, so +/// pair long work with watchdog::WatchdogManager. Scopes may nest. +/// Afterwards App.get_loop_component_start_time() can be earlier than a +/// millis() read taken before the scope; time the work with millis() at both ends. +/// +/// UnavoidableBlockingScope scope; +/// this->generate_key_(); +class UnavoidableBlockingScope { + public: + UnavoidableBlockingScope() : started_(MillisInternal::get()), pass_start_(App.get_loop_component_start_time()) {} + ~UnavoidableBlockingScope() { + // Move the pass start seen at entry forward by the time spent here, clamped to now + const uint32_t now = MillisInternal::get(); + const uint32_t moved = this->pass_start_ + (now - this->started_); + App.set_loop_component_start_time_(static_cast(now - moved) < 0 ? now : moved); + } + UnavoidableBlockingScope(const UnavoidableBlockingScope &) = delete; + UnavoidableBlockingScope &operator=(const UnavoidableBlockingScope &) = delete; + + private: + uint32_t started_; + uint32_t pass_start_; +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 35c2a17c24..e7cf1c1143 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -19,11 +19,12 @@ namespace esphome { /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. template class TemplatableFn { public: - TemplatableFn() = default; + constexpr TemplatableFn() = default; TemplatableFn(std::nullptr_t) = delete; // Exact return type match — direct function pointer storage - template TemplatableFn(F f) requires std::convertible_to : f_(f) {} + // constexpr so codegen can place arrays of these in flash. + template constexpr TemplatableFn(F f) requires std::convertible_to : f_(f) {} // Convertible return type (e.g., int -> uint8_t) — casting trampoline. // Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside @@ -576,20 +577,22 @@ template class Action { /// the following actions in the chain. int num_running_total() { int total = this->num_running_; - if (this->next_ != nullptr) - total += this->next_->num_running_total(); + if (Action *next = this->chain_next_()) + total += next->num_running_total(); return total; } protected: friend ActionList; - template friend class ContinuationAction; + /// For a branch owner (if, while), play() runs when the branch finishes. virtual void play(const Ts &...x) = 0; void play_next_(const Ts &...x) { if (this->num_running_ > 0) { this->num_running_--; - if (this->next_ != nullptr) { + if (this->next_is_owner_()) { + this->owner_()->play(x...); + } else if (this->next_ != nullptr) { this->next_->play_complex(x...); } } @@ -603,15 +606,24 @@ template class Action { virtual void stop() {} void stop_next_() { - if (this->next_ != nullptr) { - this->next_->stop_complex(); - } + if (Action *next = this->chain_next_()) + next->stop_complex(); } bool is_running_next_() { - if (this->next_ == nullptr) - return false; - return this->next_->is_running(); + Action *next = this->chain_next_(); + return next != nullptr && next->is_running(); + } + + // A branch's last action points at its owner with the low bit set. Only Action objects are + // tagged, never function pointers, which use that bit on ARM Thumb (#9283). + static constexpr uintptr_t OWNER_TAG = 1; + bool next_is_owner_() const { return reinterpret_cast(this->next_) & OWNER_TAG; } + /// Only valid when next_is_owner_(). + Action *owner_() const { return reinterpret_cast(reinterpret_cast(this->next_) - OWNER_TAG); } + Action *chain_next_() const { return this->next_is_owner_() ? nullptr : this->next_; } + static Action *tag_owner(Action *owner) { + return reinterpret_cast(reinterpret_cast(owner) + OWNER_TAG); } Action *next_{nullptr}; @@ -621,25 +633,30 @@ template class Action { int num_running_{0}; }; +// The vtable pointer gives every instantiation this alignment. +static_assert(alignof(Action<>) >= 2, "the owner tag needs a free low bit in Action object pointers"); + template class ActionList { public: void add_action(Action *action) { // Walk to end of chain - action lists are short and only built during setup() - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; - *tail = action; + *this->tail_() = action; } void add_actions(const std::initializer_list *> &actions) { // Find tail once, then append all actions in a single pass - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; + Action **tail = this->tail_(); for (auto *action : actions) { *tail = action; tail = &action->next_; } } + /// Resume `owner` via play() when the list finishes; call once, after adding actions. + void set_owner(Action *owner) { + // Nothing to tag in an empty list + if (this->actions_ == nullptr) + return; + *this->tail_() = Action::tag_owner(owner); + } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { @@ -672,6 +689,17 @@ template class ActionList { template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } + // Lists with an owner are complete; never append after set_owner() + Action **tail_() { + Action **tail = &this->actions_; + while (*tail != nullptr) { + // Not checked in release builds: only automation.py builds these lists, appending each action and + // then finishing the branch once, so appending after set_owner() would be a codegen bug. + ESPHOME_DEBUG_ASSERT(!(*tail)->next_is_owner_()); + tail = &(*tail)->next_; + } + return tail; + } Action *actions_{nullptr}; }; diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 09cde1261d..c59e27f43a 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -278,70 +278,33 @@ template class ApplyCondition final : public Condition< bool check(const Ts &...x) override { return Fn(x...); } }; -/// Simple continuation action that calls play_next_ on a parent action. -/// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class ContinuationAction : public Action { - public: - explicit ContinuationAction(Action *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->play_next_(x...); } - - protected: - Action *parent_; -}; - -// Forward declaration for WhileLoopContinuation -template class WhileAction; - -/// Loop continuation for WhileAction that checks condition and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class WhileLoopContinuation : public Action { - public: - explicit WhileLoopContinuation(WhileAction *parent) : parent_(parent) {} - - void play(const Ts &...x) override; - - protected: - WhileAction *parent_; -}; - -// Wraps a ContinuationAction when Enabled, empty otherwise. -// Lets IfAction elide the else continuation when HasElse is false. -template struct OptionalContinuation { - ContinuationAction action; - explicit OptionalContinuation(Action *parent) : action(parent) {} -}; -template struct OptionalContinuation { - explicit OptionalContinuation(Action * /*parent*/) {} -}; - template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} // Precondition: build each branch either with one add_then/add_else call, or with // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. - // Appending the inline continuation twice forms a self-loop in the next_ chain. + // set_owner() closes the list; appending after it is a codegen bug. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->then_continuation_); + this->then_.set_owner(this); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(&this->else_continuation_.action); + this->else_.set_owner(this); } // Codegen appends branch actions one at a time, then finishes each branch once; a list literal // would be a constant array in .rodata, which is RAM on ESP8266. void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->then_continuation_); } + void finish_then() { this->then_.set_owner(this); } void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } - void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void finish_else() requires(HasElse) { this->else_.set_owner(this); } - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; + // The condition may stop this automation (e.g. script.stop in a lambda) if (this->condition_->check(x...)) { if (!this->then_.empty() && this->num_running_ > 0) { this->then_.play(x...); @@ -356,8 +319,8 @@ template class IfAction : public Action { this->play_next_(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ - } + // Branch-end resume hook + void play(const Ts &...x) override { this->play_next_(x...); } void stop() override { this->then_.stop(); @@ -369,10 +332,8 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; - ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; - [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { @@ -383,14 +344,12 @@ template class WhileAction : public Action { // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->loop_continuation_); + this->then_.set_owner(this); } void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->loop_continuation_); } + void finish_then() { this->then_.set_owner(this); } - friend class WhileLoopContinuation; - - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; // Initial condition check if (!this->condition_->check(x...)) { @@ -400,12 +359,25 @@ template class WhileAction : public Action { return; } - if (this->num_running_ > 0) { - this->then_.play(x...); + // The condition may have stopped this automation + if (this->num_running_ == 0) + return; + // An empty body cannot resume the loop + if (this->then_.empty()) { + this->play_next_(x...); + return; } + this->then_.play(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ + // Branch-end resume hook: loop again or continue + void play(const Ts &...x) override { + // The condition may stop this automation + if (this->num_running_ > 0 && this->condition_->check(x...) && this->num_running_ > 0) { + this->then_.play(x...); + } else { + this->play_next_(x...); + } } void stop() override { this->then_.stop(); } @@ -413,25 +385,13 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - WhileLoopContinuation loop_continuation_{this}; }; -// Implementation of WhileLoopContinuation::play -template void WhileLoopContinuation::play(const Ts &...x) { - if (this->parent_->num_running_ > 0 && this->parent_->condition_->check(x...)) { - // play again - this->parent_->then_.play(x...); - } else { - // condition false, play next - this->parent_->play_next_(x...); - } -} - // Forward declaration for RepeatLoopContinuation template class RepeatAction; /// Loop continuation for RepeatAction that increments iteration and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +/// Repeat keeps a continuation: its body takes an extra uint32_t, so it cannot be tagged. template class RepeatLoopContinuation : public Action { public: explicit RepeatLoopContinuation(RepeatAction *parent) : parent_(parent) {} @@ -447,7 +407,7 @@ template class RepeatAction : public Action { TEMPLATABLE_VALUE(uint32_t, count) // Precondition: use either add_then or add_then_action plus one finish_then, never both - // (see IfAction::add_then). + // (see IfAction::add_then); appending the continuation twice forms a self-loop. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 42e936ee0b..914471616f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,10 @@ #define USE_BINARY_SENSOR_FILTER #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK +#define USE_BMI270_BMM150 +#define USE_BQ25186_PG_GPO +#define BQ25186_BINARY_SENSOR_COUNT 1 +#define BQ25186_TEXT_SENSOR_COUNT 1 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE @@ -73,6 +77,7 @@ #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP_NOW_HOSTED #define USE_EVENT +#define USE_EZSP_PROXY_TAP #define USE_FAN #define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT @@ -111,6 +116,8 @@ #define USE_LVGL_CANVAS #define USE_LVGL_CHART #define USE_LVGL_CHECKBOX +#define USE_LVGL_COLOR_PICKER +#define USE_LVGL_COLOR_PICKER_WHEEL #define USE_LVGL_DROPDOWN #define USE_LVGL_FONT #define USE_LVGL_GRADIENT @@ -167,6 +174,8 @@ #define USE_REMOTE_PROTOCOL_DYSON #define USE_REMOTE_PROTOCOL_GOBOX #define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_HOB2HOOD +#define USE_REMOTE_PROTOCOL_IRA211 #define USE_REMOTE_PROTOCOL_JVC #define USE_REMOTE_PROTOCOL_KEELOQ #define USE_REMOTE_PROTOCOL_LG @@ -175,16 +184,22 @@ #define USE_REMOTE_PROTOCOL_MIRAGE #define USE_REMOTE_PROTOCOL_NEC #define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_NEXUS +#define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PD_PIONEER #define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC5X #define USE_REMOTE_PROTOCOL_RC6 #define USE_REMOTE_PROTOCOL_RC_SWITCH #define USE_REMOTE_PROTOCOL_ROOMBA #define USE_REMOTE_PROTOCOL_SAMSUNG #define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SOMFY_RTS #define USE_REMOTE_PROTOCOL_SONY #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC @@ -192,6 +207,7 @@ #define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 +#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK @@ -257,6 +273,7 @@ #define USE_VALVE #define USE_WATER_HEATER #define USE_WATER_HEATER_VISUAL_OVERRIDES +#define USE_WHIRLPOOL_HISENSE_ARG #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr @@ -418,6 +435,7 @@ #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 +#define ECOCOMFORT2_CHILD_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 @@ -451,6 +469,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -461,6 +480,8 @@ #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH +#define USE_WIFI_LEASE_CACHE +#define USE_WIFI_LEASE_CACHE_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS @@ -586,6 +607,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -645,6 +667,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 @@ -717,3 +740,12 @@ #define ESPHOME_ENTITY_VALVE_COUNT 1 #define ESPHOME_ENTITY_WATER_HEATER_COUNT 1 #define ESPHOME_MAX_USB_CDC_INSTANCES 1 + +// Keep clang-tidy happy. + +// The real value comes from lvgl/gradient.py, which works it out from the configuration. The +// largest it uses is set here so that static analysis sees every gradient in the component, +// but only where a generated lv_conf.h has not already given it a value. +#ifndef LV_GRADIENT_MAX_STOPS +#define LV_GRADIENT_MAX_STOPS 7 +#endif diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1b332515f6..906f9fb12a 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -293,6 +293,26 @@ size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *na return total_len; } +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len) { + if (buffer_size == 0) { + return 0; + } + const size_t max_len = buffer_size - 1; + if (name_len <= max_len) { + memcpy(buffer, name, name_len); + buffer[name_len] = '\0'; + return name_len; + } + if (keep_suffix_len > max_len) { + keep_suffix_len = max_len; + } + const size_t head_len = max_len - keep_suffix_len; + memcpy(buffer, name, head_len); + memcpy(buffer + head_len, name + name_len - keep_suffix_len, keep_suffix_len); + buffer[max_len] = '\0'; + return max_len; +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c54d1bc16f..bcb03b010f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1256,6 +1256,16 @@ static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len); +/// Length of the suffix name_add_mac_suffix appends: "-" plus 6 characters of the MAC address +static constexpr size_t MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN = 7; + +/// Copy a name into a buffer, cutting it when it does not fit. +/// A name that is cut keeps its last keep_suffix_len characters (for example a MAC suffix) and loses +/// the middle instead; pass 0 to cut the end. +/// @param buffer Output buffer; holds at most buffer_size - 1 characters plus the null terminator +/// @return Length written (excluding null terminator) +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len); + ///@} /// @name Parsing & formatting @@ -2214,7 +2224,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_malloc_prefer(size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_malloc_prefer(size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2229,7 +2239,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_realloc_prefer(p, size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2301,12 +2311,21 @@ template class RAMAllocator { private: #ifdef USE_ESP32 - /// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags. + struct Caps { + uint32_t primary; + uint32_t fallback; + uint8_t num; // distinct capability sets to hand to heap_caps_*_prefer + constexpr Caps(uint32_t primary_caps, uint32_t fallback_caps) + : primary(primary_caps), fallback(fallback_caps), num(primary_caps == fallback_caps ? 1 : 2) {} + }; + + /// Returns the capability sets for heap_caps_*_prefer based on the configured flags. /// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set /// the primary is internal and the fallback is external. Otherwise the primary is whichever region - /// is enabled (external preferred when both are enabled), and the fallback is the other region (or - /// the same region when only one is enabled, making the second attempt a no-op). - std::array get_caps_() const { + /// is enabled (external preferred when both are enabled), and the fallback is the other region (or the + /// same region when only one is enabled). With a single region enabled num is 1, so the duplicate + /// fallback is never searched. + constexpr Caps get_caps_() const { constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; if (this->flags_ & PREFER_INTERNAL) { diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 7297d22357..c9b8454078 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -51,6 +51,7 @@ class MillisInternal { } friend class Application; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; }; } // namespace esphome diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index f9e2e7ed47..4e388a4d09 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -189,6 +189,25 @@ inline bool operator==(const __FlashStringHelper *lhs, const StringRef &rhs) { r inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { return !(lhs == rhs); } inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } + +/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. +struct ProgmemStringRef { + PGM_P str; + size_t len; +}; + +inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } + +inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { @@ -324,6 +343,7 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { // NOLINTEND(readability-identifier-naming,google-runtime-int,readability-non-const-parameter) #ifdef USE_JSON +// A StringRef may view a scratch buffer (mqtt topics do), so the document copies it // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { // Bounded by the view length; a null, empty view becomes "" rather than JSON null @@ -335,6 +355,11 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { // constructor and asks for empty() instead dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); } + +namespace json { +// For a StringRef that outlives the document, see json_util.h. A null, empty view links "" too +inline JsonString linked(const StringRef &s) { return JsonString(s.empty() ? "" : s.c_str(), s.size(), true); } +} // namespace json #endif // USE_JSON } // namespace esphome diff --git a/esphome/espota2.py b/esphome/espota2.py index dbdf323366..d8a2ff9000 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -54,6 +54,7 @@ RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94 +RESPONSE_ERROR_UPDATE_BUSY = 0x95 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -203,6 +204,10 @@ _ERROR_MESSAGES: dict[int, str] = { "of the YAML this upload uses, or update your esphome installation if it " "predates OTA encryption." ), + RESPONSE_ERROR_UPDATE_BUSY: ( + "The device is busy with another update that writes to its flash. " + "Wait for it to finish, then retry." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } @@ -215,14 +220,14 @@ class OTANetworkError(OTAError): """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" -# Remove before 2027.3.0 -class OTAEncryptionFallback(OTAError): - """The encrypted attempt failed and the caller may retry in plaintext.""" +class OTAKeyRejected(OTAError): + """The Noise handshake failed on the key; another key may succeed.""" -# Uploader side option under `ota: encryption:`; the ota component imports the -# name so the upload path never loads the component module +# Uploader side options under `ota: encryption:`; the ota component imports +# the names so the upload path never loads the component module CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +CONF_OLD_KEY = "old_key" ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " "migrates a device which never encrypted. If this device encrypted before, " @@ -249,20 +254,43 @@ PLAINTEXT_FALLBACK_NOTICE = ( ) -# Remove before 2027.3.0 -class _EncryptionAttempt: - """The key an upload tries and whether it may fall back to plaintext; - a rejected handshake falls back at once, a transport fault only on repeat.""" +OLD_KEY_REMOVE_NOTICE = ( + "Once the device has booted this firmware it runs the current key; remove " + f"'{CONF_OLD_KEY}' from 'ota: encryption:'" +) - def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None: + +class _EncryptionAttempt: + """The key an upload tries, the previous key to retry with once, and + (until 2027.3.0) whether it may fall back to plaintext.""" + + def __init__( + self, noise_psk: str | None, plaintext_fallback: bool, old_noise_psk: str | None + ) -> None: self.noise_psk = noise_psk + self.old_noise_psk = old_noise_psk + self.tried_old_key = False self.plaintext_fallback = plaintext_fallback self.handshake_faults = 0 + def retry_with_old_key(self) -> bool: + """Swap in the previous key once; the device may not run the new one yet.""" + if not self.old_noise_psk: + return False + _LOGGER.warning( + "Device rejected the OTA encryption key; retrying with '%s'", CONF_OLD_KEY + ) + self.noise_psk = self.old_noise_psk + self.old_noise_psk = None + self.tried_old_key = True + return True + + # Remove before 2027.3.0 def handshake_fault_falls_back(self) -> bool: self.handshake_faults += 1 return self.plaintext_fallback and self.handshake_faults >= 2 + # Remove before 2027.3.0 def downgrade(self, reason: str) -> None: _LOGGER.warning( "%s. Retrying in plaintext; a device that requires encryption " @@ -459,18 +487,20 @@ class NoiseSocketWrapper: if payload[0] != NOISE_HANDSHAKE_OK: reason = payload[1:].decode("utf-8", "replace") if reason == NOISE_MAC_FAILURE_REASON: - raise OTAError( + raise OTAKeyRejected( "Device rejected the handshake; is the OTA encryption key correct?" ) raise OTAError(f"Device rejected the noise handshake: {reason}") try: self._handshake.read_message(payload[1:]) - except (ValueError, self._invalid_tag) as err: - # InvalidTag is a wrong key; ValueError covers a device sending an - # invalid curve point, which cryptography rejects during the DH - raise OTAError( + except self._invalid_tag as err: + raise OTAKeyRejected( "Noise handshake failed; is the OTA encryption key correct?" ) from err + except ValueError as err: + # An invalid curve point, rejected by cryptography during the DH; + # the device is broken, not the key + raise OTAError(f"Noise handshake failed: {err}") from err self._encrypt, self._decrypt = self._handshake.get_ciphers() def sendall(self, data: bytes) -> None: @@ -652,11 +682,6 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - except OTAError as err: - # Remove before 2027.3.0 - if plaintext_fallback: - raise OTAEncryptionFallback(str(err)) from err - raise _LOGGER.info("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) @@ -874,6 +899,7 @@ def run_ota_impl_( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -912,11 +938,12 @@ def run_ota_impl_( total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" reached_device = False + reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 - encryption = _EncryptionAttempt(noise_psk, plaintext_fallback) + encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] - if reached_device or attempt >= len(res): + if not reconnect and (reached_device or attempt >= len(res)): _LOGGER.info( "Retrying in %.0f seconds (attempt %d of %d)...", UPLOAD_RETRY_DELAY, @@ -925,6 +952,7 @@ def run_ota_impl_( ) time.sleep(UPLOAD_RETRY_DELAY) reached_device = False + reconnect = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -951,11 +979,22 @@ def run_ota_impl_( encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, ) - except OTAEncryptionFallback as err: - # Same address and attempt budget: not a network retry + except OTAKeyRejected as err: + # The device sent a reject and closed on its own, so reconnect + # at once, on the same address and attempt budget last_error = str(err) - encryption.downgrade(last_error) - continue + reconnect = True + if encryption.retry_with_old_key(): + continue + # Remove before 2027.3.0 + if encryption.plaintext_fallback: + encryption.downgrade(last_error) + continue + if encryption.tried_old_key: + raise OTAKeyRejected( + f"The device rejected both 'key' and '{CONF_OLD_KEY}'" + ) from err + raise except OTAHandshakeNetworkError as err: last_error = str(err) if encryption.handshake_fault_falls_back(): @@ -976,7 +1015,13 @@ def run_ota_impl_( _LOGGER.error(str(err)) return 1, None - # Successfully uploaded to sa[0] + if encryption.tried_old_key: + # Kept next to the result: a device that should already run the + # current key but answered to the previous one is worth a look + _LOGGER.warning( + "The device accepted '%s'; it was still running the previous key", + CONF_OLD_KEY, + ) return 0, sa[0] _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) @@ -992,6 +1037,7 @@ def run_ota( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -1003,6 +1049,7 @@ def run_ota( noise_psk, plaintext_fallback, allow_plaintext_upload, + old_noise_psk, ) except OTAError as err: _LOGGER.error(err) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 670145cfe0..1ede1f4d8d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-decoder: version: 0.4.0 esphome/micro-flac: - version: 0.2.0 + version: 0.3.0 esphome/micro-mp3: version: 0.4.0 esphome/micro-opus: diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12aaae315e..769b86519f 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,12 +620,11 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient - # PlatformIO creates its HTTP cache dir without exist_ok, so two builds - # making their first registry lookup at once race on it - (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + from esphome.platformio.runner import ensure_http_cache_dir + + ensure_http_cache_dir() class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 991164eb99..a86b5a63b9 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -841,6 +841,10 @@ def _prefetch(build_dir: Path, env: str) -> None: from platformio.package.meta import PackageCompatibility, PackageSpec from platformio.platform.factory import PlatformFactory + from esphome.platformio.runner import ensure_http_cache_dir + + # The resolve and install pools each build their own registry client + ensure_http_cache_dir() platform_spec, config = _project_platform_and_config( build_dir / "platformio.ini", env ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index b9fbdec38d..cde79af0b6 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -106,6 +106,17 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def ensure_http_cache_dir() -> None: + """Create PlatformIO's HTTP cache dir before anything resolves a package. + + PlatformIO creates it without exist_ok on the first registry lookup, so + concurrent lookups (the prefetch's resolve threads, or two builds) race. + """ + from platformio.project.helpers import get_project_cache_dir + + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + + def patch_registry_private_packages() -> None: """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. diff --git a/esphome/util.py b/esphome/util.py index dd1998a7c2..d090caeef1 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -130,6 +130,17 @@ def safe_input(prompt=""): return input() +def read_secret_line(prompt: str) -> str: + """Read a secret without echo on a terminal, or one line from a pipe.""" + if sys.stdin.isatty(): + import getpass + + return getpass.getpass(prompt).strip() + # Still say what is awaited, so a pipe held open is not a silent hang + print(prompt, end="", file=sys.stderr, flush=True) + return sys.stdin.readline().strip() + + def shlex_quote(s: str | Path) -> str: # Convert Path objects to strings if isinstance(s, Path): diff --git a/esphome/wizard.py b/esphome/wizard.py index 897d5f60a1..10ea0d0b4c 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,4 +1,3 @@ -import base64 from pathlib import Path import secrets import string @@ -7,6 +6,7 @@ import unicodedata import voluptuous as vol +from esphome.components.noise import generate_encryption_key import esphome.config_validation as cv from esphome.const import ALLOWED_NAME_CHARS, ENV_QUICKWIZARD from esphome.core import CORE @@ -516,9 +516,7 @@ def wizard(path: Path) -> int: safe_print() sleep(0.5) - # Generate encryption key (32 bytes, base64 encoded) for secure API communication - noise_psk = secrets.token_bytes(32) - api_encryption_key = base64.b64encode(noise_psk).decode() + api_encryption_key = generate_encryption_key() safe_print( "For secure API communication, I've generated a random encryption key." diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e892664724..b5e2ec17e8 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -11,7 +11,8 @@ import logging import math import os from pathlib import Path -from typing import Any +import re +from typing import Any, NamedTuple import uuid import yaml @@ -46,6 +47,12 @@ _LOGGER = logging.getLogger(__name__) SECRET_YAML = "secrets.yaml" _SECRET_CACHE = {} _SECRET_VALUES = {} +# Every !secret name the loader resolved; _SECRET_VALUES is keyed by value, so +# names sharing a value collide there. +_LOADED_SECRET_NAMES: set[str] = set() +# Set while a secret_values_registered context is active; the dumper records +# every emitted `!secret` name into it. +_EMITTED_SECRET_NAMES: set[str] | None = None # Not thread-safe — config processing is single-threaded today. _load_listeners: list[Callable[[Path], None]] = [] @@ -81,7 +88,13 @@ def _raise_invalid(message: str) -> None: raise Invalid(message) -class SensitiveStr(str): +class Sensitive: + """Marker base for validated values masked in user-visible YAML output.""" + + __slots__ = () + + +class SensitiveStr(Sensitive, str): """Marker subclass for validated strings that should be masked in user-visible YAML output. ``cv.sensitive`` wraps validated values in this type so ``dump()`` can render them with ANSI conceal codes without @@ -91,6 +104,16 @@ class SensitiveStr(str): __slots__ = () +class SensitiveInt(Sensitive, int): + """Marker subclass for integer credentials masked in user-visible YAML.""" + + __slots__ = () + + +class SensitiveHexInt(core.HexInt, SensitiveInt): + """Keep hexadecimal formatting and code generation for sensitive integers.""" + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -168,10 +191,23 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: If the value is already tagged, the new context vars are merged with existing ones, with new vars taking precedence. Returns the value tagged with ConfigContext. Returns the original value if value is not a list/string/dict. + + Raises EsphomeError if a `defaults:` key is present but is not a mapping. """ if isinstance(value, dict) and CONF_DEFAULTS in value: + defaults = value.pop(CONF_DEFAULTS) + if defaults is None: + raise EsphomeError( + f"'{CONF_DEFAULTS}' is empty; " + f"remove the key or give it at least one value" + ) + if not isinstance(defaults, dict): + raise EsphomeError( + f"'{CONF_DEFAULTS}' must be a key to value mapping, " + f"got {type(defaults)}" + ) context_vars = { - **value.pop(CONF_DEFAULTS), + **defaults, **(context_vars or {}), } @@ -387,9 +423,11 @@ def _load_include_candidates( include: IncludeFile, *, warn_on_unresolved: bool, + warn_on_load_error: bool, seen: set[int], expanded_paths: set[Path], keepalive: list[Any], + result: ForceLoadResult, ) -> None: """Load every filesystem candidate for an unresolved ``IncludeFile``.""" from voluptuous import Invalid @@ -402,6 +440,7 @@ def _load_include_candidates( include.file, include.parent_file, ) + result.unresolved.append(str(include.file)) return _LOGGER.debug( "Expanding !include %s (referenced from %s) to %d candidate file(s)", @@ -416,38 +455,53 @@ def _load_include_candidates( try: loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: - # Unlike an unresolved pattern (expected during the discovery - # re-parse), a matched on-disk candidate that fails to load is a - # genuine user error; warn in every mode. The file itself is - # still tracked (the load listener fires before parsing), only - # its nested includes go undiscovered. - _LOGGER.warning( + # A real user error. The file stays tracked (the listener fires + # before parsing); only its nested includes are missed. + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load candidate %s for !include %s: %s", candidate, include.file, err, ) + result.errors.append(f"{candidate}: {err}") continue # The throwaway IncludeFile is this tree's only owner; keep the tree # alive so ids recorded in ``seen`` stay unique for the traversal. keepalive.append(loaded) - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=seen, _expanded_paths=expanded_paths, _keepalive=keepalive, + _result=result, ) +# `!secret key`, quoted or not, also inside flow collections. A text scan, not a +# YAML parse, so it can also match comments; that errs toward more secrets. +_SECRET_REFERENCE_RE = re.compile(r"""!secret\s+['"]?([^\s'",\]}]+)""") + + +def find_secret_references(text: str) -> set[str]: + """Return the ``!secret `` names referenced in a YAML document text.""" + return {match.group(1) for match in _SECRET_REFERENCE_RE.finditer(text)} + + +class ForceLoadResult(NamedTuple): + """Templated includes that matched no file, and includes that failed to load. + + Either being non-empty means the walk was incomplete. + """ + + unresolved: list[str] + errors: list[str] + + def force_load_include_files( - obj: Any, - *, - warn_on_unresolved: bool = True, - _seen: set[int] | None = None, - _expanded_paths: set[Path] | None = None, - _keepalive: list[Any] | None = None, -) -> None: + obj: Any, *, warn_on_unresolved: bool = True, warn_on_load_error: bool = True +) -> ForceLoadResult: """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved @@ -462,8 +516,30 @@ def force_load_include_files( expression could select. By default a warning is logged when no candidate exists; pass ``warn_on_unresolved=False`` (used by discovery paths that run on a fresh re-parse where substitutions haven't been applied yet) to - demote it to a debug log. + demote it to a debug log. Callers that report ``errors`` themselves pass + ``warn_on_load_error=False`` so load failures are not logged twice. """ + result = ForceLoadResult([], []) + _force_load_include_files( + obj, + warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, + _result=result, + ) + return result + + +def _force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + warn_on_load_error: bool = True, + _seen: set[int] | None = None, + _expanded_paths: set[Path] | None = None, + _keepalive: list[Any] | None = None, + _result: ForceLoadResult, +) -> None: + """Walk for :func:`force_load_include_files`, adding to ``result``.""" from voluptuous import Invalid if _seen is None: @@ -486,51 +562,60 @@ def force_load_include_files( _load_include_candidates( obj, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, seen=_seen, expanded_paths=_expanded_paths, keepalive=_keepalive, + result=_result, ) return try: loaded = obj.load() except (EsphomeError, Invalid) as err: - _LOGGER.warning( + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load !include %s (referenced from %s): %s", obj.file, obj.parent_file, err, ) + _result.errors.append(f"{obj.file}: {err}") return - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, dict): if id(obj) in _seen: return _seen.add(id(obj)) for value in obj.values(): - force_load_include_files( + _force_load_include_files( value, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, (list, tuple)): if id(obj) in _seen: return _seen.add(id(obj)) for item in obj: - force_load_include_files( + _force_load_include_files( item, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) @@ -542,11 +627,14 @@ class DiscoveredYamlFiles: were re-parsing the user's config; ``secrets`` is the subset whose *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so a ``secrets.yaml`` symlinked to a differently-named target is still - flagged as secrets). + flagged as secrets). ``files`` is incomplete when ``unresolved`` (templated + include paths) or ``load_errors`` is non-empty. """ files: list[Path] = field(default_factory=list) secrets: set[Path] = field(default_factory=set) + unresolved: list[str] = field(default_factory=list) + load_errors: list[str] = field(default_factory=list) def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: @@ -576,9 +664,13 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: try: try: data = load_yaml(config_path) - except EsphomeError: - return DiscoveredYamlFiles(list(loaded), secrets) - force_load_include_files(data, warn_on_unresolved=False) + except EsphomeError as err: + return DiscoveredYamlFiles( + list(loaded), secrets, load_errors=[f"{config_path}: {err}"] + ) + unresolved, load_errors = force_load_include_files( + data, warn_on_unresolved=False, warn_on_load_error=False + ) finally: _load_listeners.remove(_capture_secret) @@ -589,7 +681,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: if path not in seen: seen.add(path) unique.append(path) - return DiscoveredYamlFiles(unique, secrets) + return DiscoveredYamlFiles(unique, secrets, unresolved, load_errors) def _add_data_ref(fn): @@ -842,6 +934,7 @@ class ESPHomeLoaderMixin: ) val = secrets[node.value] _SECRET_VALUES[str(val)] = node.value + _LOADED_SECRET_NAMES.add(node.value) return val @_add_data_ref @@ -1014,6 +1107,7 @@ def load_yaml( if clear_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + _LOADED_SECRET_NAMES.clear() return _load_yaml_internal(fname, track_document_range=track_document_range) @@ -1119,6 +1213,38 @@ def _load_yaml_internal_with_type( loader.dispose() +def registered_secret_names() -> set[str]: + """Names of all ``!secret`` keys the loader has seen since the last clear.""" + return set(_LOADED_SECRET_NAMES) + + +@contextmanager +def secret_values_registered(values: dict[str, str]) -> Generator[set[str]]: + """Make :func:`dump` write these values as ``!secret `` while active. + + Real secrets already registered win. Yields the set of ``!secret`` names the + dumper emits. A wrapped value (``!extend``, ``!remove``, ``!include``) loses its + tag rather than leaking. + """ + global _EMITTED_SECRET_NAMES # noqa: PLW0603 + added = {v: n for v, n in values.items() if v not in _SECRET_VALUES} + _SECRET_VALUES.update(added) + outer = _EMITTED_SECRET_NAMES + emitted: set[str] = set() + _EMITTED_SECRET_NAMES = emitted + try: + yield emitted + finally: + _EMITTED_SECRET_NAMES = outer + if outer is not None: + outer |= emitted + # Only drop mappings this context still owns; a real !secret loaded + # meanwhile may have registered the same value under its own name. + for value, name in added.items(): + if _SECRET_VALUES.get(value) == name: + del _SECRET_VALUES[value] + + def dump( dict_, show_secrets=False, @@ -1136,6 +1262,7 @@ def dump( if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + _LOADED_SECRET_NAMES.clear() # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML @@ -1333,7 +1460,10 @@ class ESPHomeDumper(yaml.SafeDumper): return node def represent_secret(self, value): - return self.represent_scalar(tag="!secret", value=_SECRET_VALUES[str(value)]) + name = _SECRET_VALUES[str(value)] + if _EMITTED_SECRET_NAMES is not None: + _EMITTED_SECRET_NAMES.add(name) + return self.represent_scalar(tag="!secret", value=name) def represent_stringify(self, value): if is_secret(value): @@ -1361,11 +1491,11 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_stringify(path.as_posix()) return self.represent_stringify(value) - def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: + def represent_sensitive(self, value: Sensitive) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive - # values; otherwise let ``represent_stringify`` handle ``!secret`` - # precedence and the plain-str fallthrough. Conceal sequence is - # emitted as literal ``\033`` text (not actual ESC bytes) so the + # values; otherwise use the plain string/integer representer to + # preserve ``!secret`` references and scalar types. Conceal sequence + # is emitted as literal ``\033`` text (not actual ESC bytes) so the # output matches the prior regex format and device-builder's # ``\033[8m...\033[28m`` parser keeps working. if self._redact_sensitive and not is_secret(value): @@ -1373,6 +1503,10 @@ class ESPHomeDumper(yaml.SafeDumper): tag="tag:yaml.org,2002:str", value=f"\\033[8m{value}\\033[28m", ) + # MRO dispatch picks this representer over represent_int, so + # integers have to be forwarded explicitly. + if isinstance(value, int): + return self.represent_int(value) return self.represent_stringify(value) # pylint: disable=arguments-renamed @@ -1413,14 +1547,33 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value.value) return self.represent_scalar(tag="!lambda", value=value.value, style="|") + def _represent_tagged_secret(self, tag: str, value: Any) -> yaml.ScalarNode: + # Writing the secret beats leaking the value, but the tag is lost, so + # the dumped config no longer means the same thing when loaded back. + _LOGGER.warning( + "%s value matches secret '%s'; written as !secret, dropping the %s tag", + tag, + _SECRET_VALUES[str(value)], + tag, + ) + return self.represent_secret(value) + def represent_extend(self, value): + # Consult is_secret like the other scalar representers so a payload + # equal to a registered secret is never written out in cleartext. + if is_secret(value.value): + return self._represent_tagged_secret("!extend", value.value) return self.represent_scalar(tag="!extend", value=value.value) def represent_remove(self, value): + if is_secret(value.value): + return self._represent_tagged_secret("!remove", value.value) return self.represent_scalar(tag="!remove", value=value.value) def represent_include_file(self, value): if value.vars or value.condition is not None: + # The mapping values route through the regular representers, + # which already consult is_secret. mapping = {"file": value.file} if value.vars: mapping["vars"] = value.vars @@ -1429,6 +1582,8 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) + if is_secret(value.file): + return self._represent_tagged_secret("!include", value.file) return self.represent_scalar(tag="!include", value=value.file) def represent_id(self, value): @@ -1452,8 +1607,8 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) -# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. -ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) +# MRO-walked dispatch; Sensitive precedes str and int in every marker's MRO. +ESPHomeDumper.add_multi_representer(Sensitive, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/platformio.ini b/platformio.ini index 7b3414a32a..0d6338d6cd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.9.0 ; dsmr + esphome/dsmr_parser@1.10.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library diff --git a/requirements.txt b/requirements.txt index 612bda6b1a..a9dbaa3b8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.9.0 +aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8bbb9ed7f9..f7680f105c 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -83,6 +83,7 @@ ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", + "ecocomfort2": "Needs esp32_ble with io_capability: none and sc_bond, which a merged esp32_ble: block overrides", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", diff --git a/tests/benchmarks/components/runtime_image/__init__.py b/tests/benchmarks/components/runtime_image/__init__.py new file mode 100644 index 0000000000..775fda50c1 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/__init__.py @@ -0,0 +1,12 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in benchmark builds; the JPEG decoder is normally + # enabled by process_runtime_image_config(), so enable it here. + async def to_code_benchmark(config: ConfigType) -> None: + enable_format("JPEG") + + manifest.to_code = to_code_benchmark diff --git a/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp new file mode 100644 index 0000000000..900b340e9c --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include "esphome/components/runtime_image/runtime_image.h" +#include "jpeg_96x96.h" + +namespace esphome::benchmarks { + +using runtime_image::RuntimeImage; + +// A fixed-size image so every decode scales the same JPEG into the same buffer +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, int width, int height) + : RuntimeImage(runtime_image::JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {} +}; + +// One full decode session, fed the way online_image feeds a download +static bool decode(BenchImage &img, std::vector &buffer) { + if (!img.begin_decode(buffer.size(), runtime_image::JPEG)) { + return false; + } + size_t offset = 0; + while (offset < buffer.size()) { + const int consumed = img.feed_data(buffer.data() + offset, buffer.size() - offset); + if (consumed <= 0) { + return false; + } + offset += consumed; + } + return img.end_decode(); +} + +static void run(benchmark::State &state, image::ImageType type, int width, int height) { + BenchImage img(type, width, height); + std::vector buffer(JPEG_96X96, JPEG_96X96 + JPEG_96X96_SIZE); + for (auto _ : state) { + if (!decode(img, buffer)) { + state.SkipWithError("decode failed"); + return; + } + benchmark::DoNotOptimize(img.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * static_cast(width) * height); +} + +// --- JPEG into an RGB565 image: same size, half size and double size --- + +static void JpegDecode_Rgb565_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 96, 96); } +BENCHMARK(JpegDecode_Rgb565_Native); + +static void JpegDecode_Rgb565_Half(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 48, 48); } +BENCHMARK(JpegDecode_Rgb565_Half); + +static void JpegDecode_Rgb565_Double(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 192, 192); } +BENCHMARK(JpegDecode_Rgb565_Double); + +// --- JPEG into an RGB image at the same size, the generic per-pixel path for reference --- + +static void JpegDecode_Rgb_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB, 96, 96); } +BENCHMARK(JpegDecode_Rgb_Native); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/runtime_image/jpeg_96x96.h b/tests/benchmarks/components/runtime_image/jpeg_96x96.h new file mode 100644 index 0000000000..625515ec48 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/jpeg_96x96.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +namespace esphome::benchmarks { + +// 96x96 baseline JPEG, quality 85, no chroma subsampling: a gradient with rings, 3791 bytes +static const uint8_t JPEG_96X96[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x05, 0x03, 0x04, 0x04, 0x04, 0x03, 0x05, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, + 0x06, 0x07, 0x0C, 0x08, 0x07, 0x07, 0x07, 0x07, 0x0F, 0x0B, 0x0B, 0x09, 0x0C, 0x11, 0x0F, 0x12, 0x12, 0x11, 0x0F, + 0x11, 0x11, 0x13, 0x16, 0x1C, 0x17, 0x13, 0x14, 0x1A, 0x15, 0x11, 0x11, 0x18, 0x21, 0x18, 0x1A, 0x1D, 0x1D, 0x1F, + 0x1F, 0x1F, 0x13, 0x17, 0x22, 0x24, 0x22, 0x1E, 0x24, 0x1C, 0x1E, 0x1F, 0x1E, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x05, + 0x05, 0x05, 0x07, 0x06, 0x07, 0x0E, 0x08, 0x08, 0x0E, 0x1E, 0x14, 0x11, 0x14, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x60, 0x00, 0x60, 0x03, 0x01, 0x11, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xF9, 0x66, 0x0B, 0x6E, + 0x7A, 0x57, 0xED, 0x30, 0x8B, 0xB9, 0xE7, 0x53, 0xAA, 0x69, 0x5A, 0xDB, 0xF4, 0xE2, 0xBD, 0xEC, 0x12, 0x67, 0x5C, + 0x2A, 0x9A, 0x90, 0x41, 0x81, 0xD2, 0xBE, 0x96, 0x9C, 0xAD, 0x13, 0xAE, 0x9D, 0x40, 0xB8, 0x87, 0xE5, 0xE9, 0x5F, + 0x1B, 0xC4, 0x55, 0xD2, 0x83, 0x3D, 0x8C, 0x2C, 0xF5, 0x33, 0x9A, 0x0C, 0xC9, 0xD2, 0xBF, 0x01, 0xCD, 0x6A, 0xF3, + 0x57, 0x3E, 0x97, 0x0F, 0x53, 0x43, 0x77, 0x45, 0xB7, 0xE9, 0xC5, 0x7E, 0x99, 0xC1, 0x29, 0xE8, 0xCB, 0xA9, 0x54, + 0xD0, 0xD4, 0x21, 0xC4, 0x5D, 0x2B, 0xD5, 0xE3, 0x9A, 0xC9, 0x61, 0xDA, 0x3B, 0x70, 0x35, 0x35, 0x21, 0xD1, 0xAD, + 0xF3, 0x3F, 0x4E, 0xF5, 0xF9, 0x87, 0x06, 0x3E, 0x6C, 0x6D, 0xFC, 0xCF, 0x76, 0xA5, 0x4F, 0x74, 0xD8, 0xD5, 0x21, + 0xC4, 0x20, 0x62, 0xBE, 0xE7, 0xC4, 0x3A, 0xD6, 0xC2, 0x24, 0x6F, 0x80, 0xA9, 0xEF, 0x16, 0x3C, 0x3F, 0x6F, 0xF2, + 0x13, 0x8A, 0xF2, 0xFC, 0x39, 0x6D, 0x51, 0x93, 0x3D, 0x4A, 0xF5, 0x35, 0x40, 0xD0, 0x67, 0x52, 0x1C, 0x77, 0xAF, + 0x8F, 0xCC, 0xEA, 0x73, 0xF1, 0x0A, 0xB7, 0x73, 0xD8, 0xC3, 0xD4, 0xB5, 0x23, 0x53, 0x56, 0x83, 0xF7, 0x28, 0x31, + 0x5F, 0x47, 0xE2, 0x45, 0x4B, 0x61, 0x29, 0x47, 0xD0, 0xEA, 0xCB, 0xEA, 0x7B, 0xCC, 0xF2, 0xB8, 0x6D, 0x39, 0xE9, + 0x5F, 0xAD, 0xC3, 0x09, 0xA9, 0xFC, 0x5D, 0x4E, 0xB9, 0xA1, 0x6D, 0x6B, 0x8C, 0x71, 0x5E, 0xB6, 0x1E, 0x87, 0x29, + 0xD9, 0x0A, 0xC5, 0xE4, 0x87, 0x0B, 0xD2, 0xBA, 0x6B, 0xD6, 0x54, 0xE0, 0x77, 0xD1, 0xA9, 0x72, 0x0B, 0x94, 0xE2, + 0xBF, 0x2D, 0xE2, 0x7C, 0xC9, 0x28, 0xB5, 0x73, 0xDE, 0xC2, 0x48, 0xA9, 0x14, 0x1B, 0xA4, 0xE9, 0x5F, 0x8E, 0x4E, + 0xA3, 0xAF, 0x88, 0xD0, 0xFA, 0x0A, 0x55, 0x2C, 0x8E, 0x97, 0x48, 0xB5, 0xC2, 0x03, 0x8A, 0xFD, 0xBB, 0x84, 0x70, + 0xFE, 0xCE, 0x92, 0x93, 0x14, 0xEB, 0x5D, 0x8F, 0xD4, 0xE3, 0xE3, 0x15, 0xF3, 0xDC, 0x7F, 0x8F, 0x4A, 0x2E, 0x37, + 0x3D, 0x5C, 0x04, 0x89, 0x74, 0x2B, 0x5C, 0xC9, 0x9C, 0x57, 0x81, 0xC0, 0x14, 0x1C, 0xEB, 0x73, 0x9E, 0xB5, 0x6A, + 0xDA, 0x58, 0xBF, 0xAB, 0x45, 0xC0, 0x5C, 0x57, 0x77, 0x89, 0x38, 0xC5, 0xCA, 0xA9, 0xA3, 0xB7, 0x2F, 0x9F, 0x52, + 0xFE, 0x89, 0x6D, 0xB6, 0xD8, 0xB6, 0x3B, 0x57, 0xA1, 0xC0, 0xD0, 0xF6, 0x39, 0x74, 0xAA, 0x3E, 0xC7, 0x75, 0x4A, + 0xB7, 0x9D, 0x86, 0x5B, 0x41, 0xE6, 0x6A, 0x5D, 0x3B, 0xD7, 0xE7, 0xF8, 0x59, 0x7D, 0x6B, 0x88, 0xAE, 0xBB, 0x9E, + 0xDC, 0x2A, 0x72, 0xD1, 0x34, 0xB5, 0x68, 0x7E, 0x68, 0xD3, 0x15, 0xED, 0x78, 0x93, 0x88, 0xBD, 0x4A, 0x54, 0x57, + 0x91, 0xD9, 0x97, 0x4F, 0x46, 0xCF, 0x37, 0x8A, 0xD8, 0x7A, 0x57, 0xF4, 0x83, 0x84, 0x51, 0xFC, 0x4B, 0x4A, 0xA9, + 0x6A, 0x38, 0x40, 0x1D, 0x2B, 0x1A, 0xB8, 0x88, 0x53, 0x47, 0xA3, 0x4A, 0x6D, 0x8B, 0x20, 0x00, 0x57, 0xC7, 0xE7, + 0x19, 0xD4, 0x69, 0xC5, 0xEA, 0x7A, 0xF8, 0x67, 0x72, 0x94, 0xCB, 0xB8, 0xE2, 0xBF, 0x15, 0xCF, 0xB3, 0x77, 0x5A, + 0x4D, 0x26, 0x7D, 0x16, 0x19, 0xD8, 0xB5, 0xA7, 0x59, 0x97, 0x70, 0x71, 0x5C, 0xD9, 0x06, 0x06, 0x58, 0x8A, 0xC9, + 0xD8, 0xF4, 0xBD, 0xBD, 0x91, 0xD3, 0xDA, 0xDB, 0x08, 0xE1, 0xE9, 0xDA, 0xBF, 0x79, 0xC2, 0x46, 0x38, 0x2C, 0x25, + 0xDF, 0x62, 0x21, 0x57, 0x9A, 0x46, 0x7D, 0xE2, 0x79, 0x92, 0xED, 0x15, 0xF8, 0x3F, 0x17, 0x66, 0x4F, 0x15, 0x8A, + 0xE4, 0x4C, 0xFA, 0x3C, 0x1C, 0xB9, 0x63, 0x73, 0x6F, 0x45, 0xB3, 0xD9, 0x16, 0xE2, 0x2B, 0xF4, 0x3E, 0x09, 0xC1, + 0xFD, 0x5B, 0x0B, 0xED, 0x64, 0x6B, 0x52, 0xBD, 0xE5, 0x62, 0x2B, 0xE8, 0xFC, 0xCB, 0x90, 0xA0, 0x77, 0xAF, 0xCE, + 0x78, 0xCB, 0x1C, 0xF1, 0x99, 0x8A, 0xA7, 0x1E, 0xE7, 0xB9, 0x82, 0x97, 0x2C, 0x2E, 0x6E, 0x5B, 0xDB, 0x88, 0x6C, + 0x3A, 0x76, 0xAF, 0xD1, 0xE8, 0xC9, 0x65, 0xB9, 0x1B, 0x6F, 0x4D, 0x0D, 0x69, 0xD4, 0xE7, 0xAA, 0x33, 0x43, 0xB5, + 0xDF, 0x74, 0x5C, 0x8A, 0xFC, 0xF3, 0x81, 0x69, 0x3C, 0x56, 0x65, 0x3A, 0xEC, 0xF6, 0xEA, 0xD6, 0xE5, 0x82, 0x45, + 0xAB, 0xB8, 0x7C, 0xDD, 0x41, 0x10, 0x0E, 0x86, 0xB1, 0xE2, 0xBC, 0x47, 0xD7, 0xB3, 0xE8, 0x51, 0x5D, 0x19, 0xE8, + 0xE0, 0xE7, 0xCB, 0x4A, 0xE7, 0x98, 0xA3, 0x0C, 0x57, 0xEE, 0xB5, 0xB8, 0x92, 0x09, 0x6E, 0x7F, 0x14, 0xD2, 0x4C, + 0x79, 0x71, 0x5F, 0x33, 0x98, 0xF1, 0x44, 0x52, 0x76, 0x67, 0xAF, 0x42, 0x24, 0x4E, 0x4B, 0x57, 0xE7, 0x19, 0xAE, + 0x7F, 0x2A, 0xCD, 0xA4, 0xCF, 0x6F, 0x0F, 0xA1, 0x2D, 0xAD, 0xA3, 0x48, 0xC3, 0x8A, 0xF0, 0xB0, 0x98, 0x5A, 0xB8, + 0xCA, 0xA7, 0xAB, 0x0A, 0xCA, 0x28, 0xE8, 0xF4, 0xBD, 0x3F, 0x62, 0x82, 0x56, 0xBF, 0x68, 0xE1, 0xAC, 0x95, 0x61, + 0xE0, 0xA7, 0x24, 0x0F, 0x13, 0x72, 0xDD, 0xE6, 0x11, 0x36, 0x8A, 0x8E, 0x2D, 0xCF, 0x61, 0x85, 0xA2, 0xE1, 0x16, + 0x7A, 0x18, 0x3F, 0x79, 0xDC, 0xA9, 0x65, 0x6A, 0xD3, 0xCE, 0x0E, 0x3B, 0xD7, 0xE3, 0x59, 0x56, 0x1E, 0x79, 0x9E, + 0x3B, 0x99, 0xF7, 0x3E, 0x85, 0x56, 0x50, 0x89, 0xD2, 0x79, 0x22, 0xDE, 0xD7, 0x18, 0xC1, 0xC5, 0x7E, 0xD7, 0x98, + 0x62, 0x61, 0x94, 0xE5, 0xB6, 0xD9, 0xD8, 0x54, 0x27, 0xED, 0x26, 0x53, 0xD3, 0xED, 0x4C, 0xF7, 0x7B, 0x88, 0xE3, + 0x35, 0xF8, 0xC6, 0x47, 0x46, 0x79, 0xAE, 0x6B, 0xED, 0x1E, 0xD7, 0x3E, 0x87, 0xDB, 0x72, 0x42, 0xC6, 0xC6, 0xA6, + 0x9B, 0x21, 0x11, 0x8A, 0xFB, 0x6E, 0x3F, 0xCC, 0xD6, 0x17, 0x07, 0x1C, 0x34, 0x1E, 0xA6, 0xF8, 0x07, 0xCD, 0x2B, + 0x96, 0xB4, 0x6B, 0x5F, 0x2A, 0xD4, 0xC8, 0x47, 0x6A, 0xCF, 0x82, 0xF0, 0xEB, 0x01, 0x96, 0x4F, 0x15, 0x3E, 0xA8, + 0xF4, 0x6A, 0x56, 0xE7, 0x9A, 0x8A, 0x1D, 0xA5, 0xDB, 0x19, 0xF5, 0x03, 0x21, 0x1C, 0x03, 0x5F, 0x25, 0xC3, 0x31, + 0x79, 0x9E, 0x7B, 0x3C, 0x44, 0xB6, 0x4C, 0xF6, 0x1D, 0x5E, 0x4A, 0x49, 0x1E, 0x2D, 0x1A, 0xB9, 0xA9, 0x9E, 0x6F, + 0x5E, 0x47, 0xF1, 0xE5, 0x26, 0x8B, 0x11, 0x40, 0xED, 0xDA, 0xB8, 0xE5, 0x53, 0x11, 0x5D, 0x9E, 0x9D, 0x2A, 0x89, + 0x1A, 0x36, 0x9A, 0x73, 0x39, 0x19, 0x5A, 0xF4, 0xF0, 0x19, 0x15, 0x6C, 0x44, 0x93, 0x68, 0xED, 0x8E, 0x25, 0x23, + 0x7B, 0x4F, 0xD3, 0x42, 0x80, 0x48, 0xAF, 0xD5, 0x32, 0x4E, 0x1C, 0x85, 0x04, 0xA5, 0x34, 0x57, 0xD6, 0xAE, 0x68, + 0x48, 0x16, 0x14, 0xC0, 0xC5, 0x7A, 0x99, 0xCE, 0x75, 0x47, 0x2F, 0xA2, 0xE3, 0x16, 0x76, 0xE1, 0xAF, 0x36, 0x67, + 0xBA, 0x34, 0xF2, 0xE0, 0x7A, 0xD7, 0xE0, 0x79, 0xB6, 0x69, 0x57, 0x35, 0xC4, 0xF2, 0xC4, 0xFA, 0x5C, 0x3C, 0x94, + 0x11, 0xBD, 0xA4, 0x69, 0xE2, 0x24, 0x0E, 0xC2, 0xBF, 0x51, 0xE1, 0x2C, 0x96, 0x38, 0x2A, 0x3E, 0xDA, 0xA2, 0x2E, + 0x78, 0x9E, 0x67, 0x64, 0x1A, 0x81, 0xDE, 0xFE, 0x5A, 0xD7, 0xC6, 0xF1, 0xCF, 0x10, 0x3C, 0x55, 0x5F, 0xAB, 0xD2, + 0x67, 0xB1, 0x81, 0xF7, 0x55, 0xD9, 0xA7, 0xA3, 0xD8, 0x88, 0x61, 0xF3, 0x18, 0x76, 0xAF, 0xA2, 0xE0, 0xDC, 0xB2, + 0x39, 0x76, 0x11, 0xE2, 0x6A, 0xEE, 0x75, 0xD4, 0xC4, 0x73, 0xCA, 0xC8, 0x63, 0xC4, 0x6E, 0xAF, 0x42, 0x81, 0x90, + 0x0D, 0x7E, 0x7D, 0x9B, 0x62, 0xA7, 0x9E, 0xE7, 0x0A, 0x9C, 0x75, 0x49, 0x9E, 0xDE, 0x1E, 0x6A, 0x95, 0x3B, 0x9A, + 0xF7, 0x91, 0x88, 0x2D, 0x16, 0x25, 0x1C, 0x91, 0x5F, 0x67, 0xC5, 0xF8, 0xF8, 0xE5, 0x39, 0x54, 0x70, 0xB4, 0xF7, + 0x68, 0xDF, 0x07, 0x2F, 0x69, 0x3E, 0x66, 0x59, 0xD2, 0x2D, 0x44, 0x16, 0x6D, 0x33, 0x0C, 0x12, 0x2B, 0x8B, 0x84, + 0xA8, 0x2C, 0xAF, 0x29, 0xA9, 0x8C, 0xA9, 0xA3, 0x68, 0xF4, 0xE7, 0x5B, 0x9E, 0xA2, 0x8A, 0x3C, 0x86, 0x1D, 0x23, + 0xDA, 0xBE, 0xBA, 0x1C, 0x16, 0xAF, 0xAA, 0x3F, 0x8D, 0x69, 0xE3, 0x4D, 0x0B, 0x6D, 0x29, 0x46, 0x32, 0x2B, 0xD6, + 0xC2, 0xF0, 0x95, 0x2A, 0x7A, 0xC9, 0x1D, 0x90, 0xC6, 0x36, 0x69, 0xC1, 0x69, 0x1C, 0x63, 0xA0, 0xAF, 0x76, 0x18, + 0x6C, 0x26, 0x0A, 0x37, 0x76, 0x3B, 0x29, 0xD6, 0x94, 0x87, 0x4B, 0x2A, 0xA0, 0xC2, 0xD7, 0xCC, 0x67, 0x7C, 0x5F, + 0x47, 0x0B, 0x07, 0x18, 0x33, 0xD6, 0xC3, 0x41, 0xC9, 0xEA, 0x54, 0x22, 0x49, 0xDF, 0x00, 0x1A, 0xFC, 0x67, 0x32, + 0xCE, 0x31, 0x39, 0xAD, 0x5E, 0x58, 0x1F, 0x43, 0x87, 0x71, 0x82, 0x36, 0x34, 0x9D, 0x2F, 0x18, 0x77, 0x15, 0xF7, + 0x5C, 0x29, 0xC2, 0xDC, 0x96, 0xAD, 0x59, 0x1B, 0xCB, 0x17, 0x7D, 0x11, 0xA1, 0x76, 0xEB, 0x14, 0x7B, 0x12, 0xBD, + 0x1E, 0x2D, 0xE2, 0x6A, 0x58, 0x0A, 0x0E, 0x85, 0x17, 0xA9, 0xDB, 0x83, 0x8B, 0x93, 0xBB, 0x19, 0xA5, 0x58, 0x34, + 0xF3, 0x79, 0x8C, 0x38, 0xAF, 0xCF, 0xB8, 0x5F, 0x27, 0xAB, 0x9A, 0xE2, 0xFE, 0xB1, 0x57, 0x63, 0xDB, 0x96, 0x25, + 0x42, 0x36, 0x46, 0xAD, 0xF9, 0x11, 0x44, 0x22, 0x4E, 0xB5, 0xF5, 0xBC, 0x6B, 0x9F, 0xC3, 0x01, 0x86, 0x58, 0x4A, + 0x0F, 0x5D, 0x8E, 0x9C, 0x17, 0xBC, 0xF9, 0x99, 0x63, 0x44, 0xB1, 0xD8, 0xA6, 0x79, 0x05, 0x79, 0x9C, 0x0F, 0x94, + 0xAC, 0x3D, 0x29, 0x63, 0xF1, 0x1E, 0xA7, 0xA7, 0x53, 0x11, 0x77, 0xCA, 0x87, 0x98, 0x5A, 0xF2, 0xF8, 0x28, 0x19, + 0x50, 0x6B, 0xE6, 0x73, 0x1C, 0x4D, 0x4E, 0x23, 0xCE, 0x95, 0x38, 0xEB, 0x14, 0xCF, 0x5E, 0x84, 0xD5, 0x1A, 0x77, + 0x34, 0x75, 0x04, 0x09, 0x12, 0x5B, 0x20, 0xE4, 0xD7, 0xB7, 0xC7, 0x39, 0x8A, 0xC2, 0xE1, 0xA9, 0xE5, 0x98, 0x7D, + 0xDE, 0x87, 0x66, 0x06, 0x5C, 0xD2, 0xE7, 0x67, 0x94, 0xC7, 0x72, 0x9E, 0xD5, 0xFA, 0x85, 0x5E, 0x3A, 0xC3, 0xA5, + 0xA3, 0x3F, 0x8B, 0xE9, 0x52, 0x64, 0x9F, 0x6A, 0x1D, 0xAB, 0xE7, 0xF1, 0xBE, 0x20, 0xC5, 0x2F, 0x75, 0x9E, 0xAD, + 0x0A, 0x22, 0x19, 0xA4, 0x7E, 0x00, 0x35, 0xF0, 0xD9, 0x8F, 0x18, 0x62, 0xB1, 0x4D, 0xA8, 0x1E, 0xD5, 0x08, 0x46, + 0x3B, 0x93, 0x5B, 0x58, 0xCB, 0x33, 0x0C, 0x83, 0x5E, 0x56, 0x1B, 0x2B, 0xC7, 0x66, 0x75, 0x2F, 0x2B, 0x9E, 0x9C, + 0x31, 0x11, 0x82, 0x37, 0xB4, 0xFD, 0x29, 0x23, 0x01, 0x9C, 0x0A, 0xFD, 0x47, 0x23, 0xE1, 0x3A, 0x38, 0x28, 0xAA, + 0x95, 0x8D, 0x3E, 0xB8, 0xE5, 0xA2, 0x2D, 0x5C, 0x4A, 0x91, 0x2E, 0xC4, 0xEB, 0x58, 0xF1, 0x27, 0x17, 0xD0, 0xC0, + 0x52, 0x74, 0x68, 0x3D, 0x4F, 0x47, 0x09, 0x07, 0x27, 0x76, 0x43, 0x67, 0x65, 0x25, 0xD4, 0xBB, 0x98, 0x71, 0x5F, + 0x98, 0x65, 0xB9, 0x7E, 0x2F, 0x3F, 0xC5, 0xFB, 0x4A, 0x9B, 0x1E, 0xEC, 0x6B, 0xC6, 0x94, 0x6C, 0x8D, 0xFF, 0x00, + 0x2E, 0x3B, 0x3B, 0x7C, 0x0C, 0x67, 0x15, 0xFA, 0x96, 0x3B, 0x1B, 0x85, 0xE1, 0xBC, 0x0F, 0x24, 0x7E, 0x2B, 0x17, + 0x42, 0x52, 0xAD, 0x2B, 0x95, 0xEC, 0x6C, 0xDE, 0xF2, 0xE7, 0x7B, 0x0F, 0x96, 0xBF, 0x2D, 0xCA, 0x30, 0x58, 0x8E, + 0x22, 0xCC, 0x3D, 0xBD, 0x5F, 0x86, 0xE7, 0xBA, 0xAB, 0xAA, 0x50, 0xB2, 0x35, 0x6F, 0xB1, 0x14, 0x42, 0x08, 0xC7, + 0x3D, 0x2B, 0xEA, 0x38, 0xCF, 0x3E, 0x86, 0x03, 0x0C, 0xB2, 0xFC, 0x2E, 0xEF, 0x4D, 0x0E, 0xAC, 0x15, 0xE4, 0xF9, + 0xE4, 0x5B, 0xD2, 0xEC, 0xD6, 0xDA, 0xD8, 0xCF, 0x20, 0xF9, 0x88, 0xA8, 0xE1, 0x8C, 0x0C, 0x32, 0x4C, 0xBE, 0x58, + 0xFC, 0x4F, 0xC4, 0xD1, 0xE8, 0xCA, 0xBB, 0xA9, 0x35, 0x14, 0x3F, 0x4E, 0xB5, 0x37, 0x77, 0x86, 0x67, 0x1F, 0x2A, + 0x9A, 0xF9, 0xBC, 0x86, 0x85, 0x4C, 0xFF, 0x00, 0x36, 0x96, 0x3A, 0xB7, 0xC1, 0x16, 0x7A, 0xDE, 0xD9, 0x52, 0x82, + 0x8A, 0x3C, 0x76, 0x1D, 0x26, 0x53, 0xD8, 0xD7, 0xB8, 0xB8, 0x37, 0x1D, 0x27, 0xAD, 0xCF, 0xE3, 0x7A, 0x78, 0xD8, + 0x97, 0xED, 0xB4, 0x57, 0x38, 0xC8, 0xAF, 0x43, 0x0F, 0xC0, 0x55, 0xA6, 0xFD, 0xF3, 0xBA, 0x18, 0xF4, 0x6A, 0xDA, + 0x69, 0x11, 0xA6, 0x0B, 0x0A, 0xFA, 0x9C, 0x17, 0x05, 0xE1, 0x70, 0xDE, 0xF5, 0x43, 0xAA, 0x18, 0xD6, 0xF6, 0x34, + 0x11, 0x20, 0x81, 0x78, 0x03, 0x35, 0xE9, 0xD7, 0xCC, 0x72, 0xDC, 0xA6, 0x1A, 0x5A, 0xE8, 0xED, 0xA3, 0x29, 0xD4, + 0x64, 0x53, 0x5C, 0xB3, 0x9D, 0xB1, 0x8A, 0xFC, 0xCF, 0x3E, 0xE3, 0xAA, 0xB8, 0xA6, 0xE9, 0x61, 0xCF, 0x73, 0x0B, + 0x49, 0x47, 0x56, 0x4D, 0x61, 0xA6, 0x4B, 0x3B, 0x86, 0x90, 0x1C, 0x57, 0x95, 0x92, 0xF0, 0xCE, 0x2F, 0x35, 0xAA, + 0xAA, 0xE2, 0x36, 0x3D, 0x4F, 0xAD, 0xC6, 0x0A, 0xC8, 0xDB, 0x11, 0xC3, 0x67, 0x16, 0x06, 0x33, 0x5F, 0xA2, 0x63, + 0x33, 0x0C, 0x0F, 0x0D, 0xE1, 0x79, 0x61, 0x6E, 0x63, 0x5A, 0x12, 0x95, 0x69, 0x6A, 0x57, 0x86, 0xDE, 0x5B, 0xE9, + 0xFA, 0x1D, 0xB9, 0xAF, 0xCA, 0x23, 0xF5, 0xDE, 0x28, 0xC6, 0xDD, 0xDF, 0x92, 0xE7, 0xBF, 0x4E, 0xAC, 0x68, 0x44, + 0xDB, 0x31, 0xC7, 0x63, 0x6F, 0xB5, 0x47, 0xCD, 0x8A, 0xFD, 0x07, 0x30, 0xC7, 0xE1, 0x38, 0x5F, 0x01, 0xEC, 0xA9, + 0xFC, 0x6D, 0x1A, 0xD0, 0x94, 0xAB, 0xCA, 0xEC, 0x4D, 0x2E, 0xC1, 0xAE, 0x26, 0xF3, 0xE5, 0x07, 0x68, 0xE7, 0x9A, + 0xF8, 0xAE, 0x19, 0xCA, 0x2A, 0xE6, 0xF8, 0xB7, 0x98, 0x62, 0xFE, 0x15, 0xAE, 0xA7, 0xB2, 0xF1, 0x0A, 0x9C, 0x79, + 0x22, 0x5C, 0xBB, 0x06, 0xE2, 0x65, 0xB7, 0x84, 0x7C, 0xA3, 0x83, 0x8A, 0x5C, 0x57, 0x9C, 0xCF, 0x37, 0xC5, 0x47, + 0x2D, 0xC1, 0xFC, 0x2B, 0x4D, 0x0F, 0x43, 0x06, 0xD5, 0x38, 0xF3, 0xC8, 0xD0, 0x78, 0x96, 0xCA, 0xD0, 0x44, 0x83, + 0xE7, 0x6A, 0xF4, 0xF3, 0x7C, 0x5D, 0x2E, 0x19, 0xCA, 0x56, 0x12, 0x8F, 0xF1, 0x24, 0x8E, 0xDC, 0x3C, 0xDD, 0x6A, + 0x9C, 0xCF, 0x63, 0xC8, 0xE3, 0xBB, 0x8B, 0xB0, 0x15, 0xF7, 0xB5, 0x3C, 0x44, 0xC2, 0x25, 0xA2, 0x3F, 0x8C, 0xE9, + 0x50, 0x91, 0x30, 0xBD, 0x1F, 0xC2, 0xB5, 0xE2, 0xE2, 0xFC, 0x4A, 0x5B, 0x53, 0x47, 0xA9, 0x47, 0x0F, 0xDC, 0x70, + 0x9E, 0x69, 0x38, 0x55, 0x35, 0xF2, 0x78, 0xCE, 0x34, 0xCC, 0x71, 0x8F, 0x96, 0x92, 0x67, 0xB1, 0x42, 0x9C, 0x23, + 0xB9, 0x62, 0xDB, 0x4F, 0xB8, 0x9C, 0x82, 0xC0, 0xE2, 0xB8, 0xB0, 0xF9, 0x26, 0x6B, 0x9A, 0xCE, 0xF5, 0x2F, 0x63, + 0xD2, 0x86, 0x26, 0x10, 0xD8, 0xDA, 0xB2, 0xD2, 0x62, 0x84, 0x06, 0x93, 0x15, 0xF7, 0xB9, 0x5F, 0x07, 0xE1, 0x32, + 0xE8, 0xFB, 0x5C, 0x4B, 0xD4, 0xD9, 0x63, 0x65, 0x3D, 0x22, 0x59, 0x9A, 0x78, 0xE2, 0x5D, 0x91, 0x01, 0x9F, 0x6A, + 0xE4, 0xCF, 0x78, 0xDF, 0x0D, 0x80, 0x83, 0xA1, 0x83, 0xDF, 0xC8, 0xF4, 0xB0, 0xD4, 0xE5, 0x27, 0x79, 0x0D, 0xB4, + 0xB1, 0x9A, 0xF2, 0x4D, 0xCE, 0x0E, 0xDA, 0xF8, 0x5C, 0x06, 0x53, 0x98, 0x71, 0x16, 0x23, 0xDA, 0xD7, 0xBF, 0x29, + 0xED, 0x47, 0x11, 0x0A, 0x4A, 0xC8, 0xD9, 0xD9, 0x0D, 0x8C, 0x3B, 0x54, 0x0D, 0xD5, 0xF7, 0x78, 0xDC, 0xCB, 0x01, + 0xC2, 0xF8, 0x4F, 0x67, 0x4A, 0xCE, 0x66, 0xF4, 0x25, 0x3A, 0xF2, 0xBB, 0x1B, 0x65, 0x65, 0x2D, 0xEC, 0xDE, 0x64, + 0x83, 0x09, 0x5F, 0x03, 0x96, 0x65, 0xB8, 0xCE, 0x25, 0xC6, 0x7D, 0x67, 0x13, 0xA4, 0x11, 0xED, 0x2A, 0xF1, 0xA3, + 0x1B, 0x2D, 0xCD, 0x2B, 0x96, 0x58, 0x90, 0x5B, 0xC0, 0x39, 0xE9, 0xC5, 0x7B, 0x7C, 0x51, 0xC4, 0x54, 0xF0, 0x34, + 0x56, 0x5B, 0x97, 0xFC, 0x4F, 0x4D, 0x0E, 0xCC, 0x22, 0x72, 0x7C, 0xF3, 0x2D, 0xE9, 0xF6, 0x49, 0x69, 0x01, 0x9E, + 0x60, 0x37, 0x9F, 0x5A, 0x59, 0x1E, 0x5F, 0x47, 0x87, 0xB0, 0x52, 0xCC, 0x31, 0x9F, 0x1B, 0xD5, 0x5C, 0xF4, 0x3D, + 0xBB, 0xAB, 0x2E, 0x48, 0xEC, 0x4B, 0x61, 0x68, 0xD7, 0x97, 0x26, 0xE2, 0x51, 0x88, 0xD6, 0xBC, 0x1C, 0x9F, 0x0D, + 0x57, 0x88, 0xB3, 0x09, 0x66, 0x38, 0xBD, 0x29, 0xC7, 0x54, 0x7A, 0xCA, 0xB2, 0xA3, 0x1E, 0x48, 0xEE, 0x79, 0x3C, + 0x3A, 0x1A, 0x77, 0x35, 0xFA, 0x4A, 0xF0, 0xEA, 0x8A, 0xDE, 0x47, 0xF1, 0x85, 0x3C, 0xC1, 0x97, 0x60, 0xD1, 0xE0, + 0x5F, 0xBC, 0x45, 0x75, 0x52, 0xE0, 0x8C, 0xBA, 0x8E, 0xB5, 0x24, 0x8E, 0xEA, 0x78, 0xE9, 0xBD, 0x8B, 0xD0, 0xDA, + 0xDA, 0x42, 0x3B, 0x57, 0x57, 0xB3, 0xC8, 0xF2, 0xD5, 0x76, 0xD6, 0x87, 0x7D, 0x2A, 0xB5, 0x66, 0x4A, 0x6E, 0xA2, + 0x41, 0x88, 0xD6, 0xBC, 0x1C, 0xC7, 0xC4, 0x1C, 0x1E, 0x15, 0x38, 0x61, 0xA3, 0xA9, 0xEA, 0xD0, 0xA3, 0x29, 0x7C, + 0x43, 0x55, 0x6E, 0xAE, 0x9B, 0x0A, 0x08, 0x15, 0xF0, 0xB8, 0x9C, 0xDF, 0x38, 0xCF, 0x67, 0xCB, 0x4D, 0x34, 0x99, + 0xEC, 0xD1, 0x74, 0xE9, 0x2D, 0x4D, 0x4B, 0x1D, 0x1D, 0x53, 0x12, 0x4E, 0x7F, 0x3A, 0xFA, 0x4C, 0xA3, 0x82, 0x69, + 0x61, 0xD7, 0xD6, 0x31, 0xF2, 0xFB, 0xCE, 0xB5, 0x8E, 0x6F, 0x48, 0x97, 0x25, 0x9D, 0x22, 0x5F, 0x2A, 0x05, 0xC9, + 0xF6, 0xA3, 0x3C, 0xE3, 0x4C, 0x36, 0x02, 0x1F, 0x55, 0xCB, 0xD5, 0xDE, 0xDA, 0x1E, 0x86, 0x1A, 0x9B, 0x93, 0xE6, + 0x99, 0x25, 0x86, 0x99, 0x25, 0xC3, 0xF9, 0xB3, 0xE4, 0x2F, 0x5E, 0x6B, 0xE7, 0x72, 0x7E, 0x1B, 0xC5, 0xE6, 0xF5, + 0x7E, 0xB9, 0x8F, 0x76, 0x8E, 0xFA, 0x9E, 0xB2, 0xC5, 0xC6, 0x9A, 0xE5, 0x89, 0xA1, 0x3C, 0x8B, 0x12, 0x88, 0x2D, + 0xD7, 0x9E, 0x9C, 0x57, 0xA7, 0xC4, 0x1C, 0x55, 0x47, 0x03, 0x4F, 0xFB, 0x3F, 0x2C, 0x57, 0x96, 0xDA, 0x1D, 0xB8, + 0x58, 0xB9, 0x3E, 0x79, 0x96, 0x74, 0xED, 0x3D, 0x61, 0x5F, 0xB4, 0xDC, 0xF5, 0xEB, 0xCD, 0x73, 0x64, 0x39, 0x1D, + 0x3C, 0xBA, 0x9B, 0xCC, 0xF3, 0x37, 0xEF, 0x6F, 0xA9, 0xE9, 0x7D, 0x65, 0xCD, 0xF2, 0x40, 0x99, 0x21, 0x93, 0x50, + 0xB8, 0x00, 0x02, 0x22, 0x5A, 0xF1, 0x2B, 0xD7, 0xC5, 0xF1, 0x86, 0x61, 0xC9, 0x0D, 0x28, 0xC7, 0xEE, 0x3D, 0x4A, + 0x35, 0x23, 0x87, 0x85, 0xFA, 0x97, 0xA7, 0x50, 0x8A, 0xB6, 0x76, 0xC3, 0x93, 0xC1, 0x22, 0xBB, 0x38, 0x8B, 0x37, + 0x8E, 0x1E, 0x9C, 0x72, 0x5C, 0xAF, 0x77, 0xA3, 0x68, 0xEF, 0xC2, 0xBB, 0xBF, 0x69, 0x33, 0xC6, 0xA2, 0x5B, 0xD3, + 0xFC, 0x26, 0xBD, 0x19, 0x66, 0xBC, 0x43, 0x3D, 0x2C, 0xCF, 0xE3, 0x9A, 0x4E, 0x92, 0x2D, 0x45, 0x69, 0x7B, 0x27, + 0x63, 0x58, 0xBC, 0x37, 0x11, 0x62, 0xF4, 0x77, 0x3D, 0x1A, 0x55, 0xA9, 0x44, 0xBD, 0x6F, 0xA3, 0xCE, 0xFC, 0xB9, + 0x35, 0xD3, 0x43, 0x81, 0xF3, 0x2C, 0x53, 0xBD, 0x79, 0x9D, 0xF4, 0xF1, 0xD0, 0x8E, 0xC6, 0x9D, 0xB6, 0x93, 0x6F, + 0x16, 0x0C, 0x84, 0x1A, 0xFA, 0x0C, 0x37, 0x06, 0xE5, 0x99, 0x7A, 0xE7, 0xC4, 0xCD, 0x36, 0x75, 0xC3, 0x1B, 0x39, + 0x7C, 0x25, 0xBF, 0x3A, 0xDA, 0x01, 0xB6, 0x25, 0x04, 0xD4, 0x63, 0xB8, 0xC3, 0x2A, 0xCA, 0x63, 0xC9, 0x84, 0x8A, + 0x6C, 0xF4, 0x68, 0x42, 0x73, 0xD6, 0x42, 0x22, 0x5D, 0x5E, 0x36, 0x15, 0x48, 0x5A, 0xF8, 0x9A, 0xF9, 0x96, 0x75, + 0xC4, 0x75, 0x39, 0x29, 0xA6, 0xA2, 0xCF, 0x66, 0x8C, 0xA9, 0xD1, 0x5A, 0x9A, 0x96, 0x7A, 0x64, 0x36, 0xCB, 0xE6, + 0x4E, 0x41, 0x6A, 0xFA, 0x4C, 0xBF, 0x86, 0xB2, 0xFC, 0x92, 0x1F, 0x59, 0xC7, 0xC9, 0x39, 0x1D, 0x51, 0xC5, 0xCE, + 0xA3, 0xB4, 0x09, 0x1E, 0x59, 0x2E, 0x1B, 0xC9, 0xB7, 0x4C, 0x2F, 0x4E, 0x2B, 0xE7, 0xF3, 0x7E, 0x2C, 0xC5, 0xE6, + 0xF3, 0xFA, 0x9E, 0x59, 0x1B, 0x47, 0x6D, 0x0F, 0x5B, 0x0F, 0x08, 0xD3, 0x5C, 0xD3, 0x34, 0x2C, 0xEC, 0x22, 0xB4, + 0x4F, 0x3A, 0x72, 0x0B, 0xD7, 0xA5, 0x97, 0x64, 0xB8, 0x2E, 0x1E, 0xA3, 0xF5, 0xDC, 0xC2, 0x57, 0xA9, 0xBE, 0xA7, + 0x6C, 0x71, 0x32, 0xAA, 0xF9, 0x61, 0xB1, 0x22, 0x43, 0x36, 0xA1, 0x36, 0x00, 0x2B, 0x10, 0xAF, 0x9D, 0xAD, 0x88, + 0xC7, 0xF1, 0x86, 0x2F, 0x92, 0x1E, 0xED, 0x15, 0xF9, 0x1E, 0xB5, 0x19, 0xC7, 0x0F, 0x1D, 0x77, 0x2F, 0xB8, 0x4B, + 0x68, 0xC5, 0xB5, 0xB0, 0xCB, 0x9E, 0x09, 0x15, 0xE8, 0xE6, 0xF9, 0xCD, 0x0C, 0x92, 0x82, 0xCB, 0x32, 0xC5, 0x7A, + 0x8F, 0x46, 0xD1, 0xDB, 0x87, 0xBD, 0x47, 0xCF, 0x3D, 0x8B, 0x56, 0x96, 0x89, 0x65, 0x0F, 0x9F, 0x37, 0x32, 0xB7, + 0x41, 0x58, 0x60, 0x30, 0xD4, 0x38, 0x67, 0x08, 0xF3, 0x0C, 0x73, 0xBD, 0x69, 0x6C, 0x8F, 0x4A, 0x35, 0x9D, 0x69, + 0x72, 0x47, 0x63, 0xC8, 0xE3, 0xBD, 0x8F, 0xB4, 0x5F, 0xA5, 0x7D, 0xB4, 0xFC, 0x4A, 0xC2, 0x2F, 0x86, 0x97, 0xE0, + 0x7F, 0x19, 0xD2, 0xA1, 0x2E, 0xE4, 0xE9, 0x7A, 0xC7, 0xEE, 0x45, 0xFA, 0x57, 0x9F, 0x5B, 0xC4, 0xBA, 0x8F, 0x4A, + 0x34, 0xBF, 0x03, 0xD3, 0xA3, 0x41, 0x75, 0x64, 0xE9, 0x25, 0xE4, 0xBC, 0x24, 0x64, 0x57, 0x91, 0x5B, 0x8B, 0x33, + 0xEC, 0x7F, 0xBB, 0x46, 0x0D, 0x1E, 0xA5, 0x18, 0xD2, 0x8E, 0xEC, 0xB5, 0x06, 0x99, 0x79, 0x39, 0xCC, 0x84, 0x81, + 0x58, 0xD3, 0xE1, 0xBC, 0xF7, 0x34, 0x77, 0xC4, 0x4D, 0xA4, 0x7A, 0x34, 0xF1, 0x54, 0xA1, 0xB1, 0xA5, 0x6D, 0xA5, + 0x5B, 0x40, 0x37, 0x4C, 0xC0, 0x9A, 0xF7, 0x30, 0xFC, 0x27, 0x94, 0xE5, 0x4B, 0xDA, 0x63, 0x2A, 0x26, 0xD1, 0xD9, + 0x0C, 0x64, 0xE7, 0xA4, 0x4B, 0x26, 0xE2, 0x38, 0xC6, 0xCB, 0x68, 0xF2, 0x7D, 0x85, 0x71, 0x66, 0x1C, 0x77, 0x86, + 0xC2, 0xAF, 0x61, 0x96, 0x53, 0xBB, 0xF2, 0x3D, 0x2A, 0x14, 0x9C, 0xB5, 0x9B, 0x25, 0xB6, 0xD3, 0xEE, 0x6E, 0xDB, + 0x7C, 0xC4, 0xAA, 0xD7, 0x87, 0x87, 0xC8, 0xB3, 0x6C, 0xFE, 0xA7, 0xB6, 0xC7, 0x4B, 0x96, 0x07, 0xAB, 0x0C, 0x4D, + 0x3A, 0x4A, 0xD1, 0xDC, 0xD1, 0x51, 0x6D, 0x64, 0x9B, 0x22, 0x50, 0xCF, 0x5E, 0xC6, 0x2B, 0x38, 0xCA, 0x78, 0x66, + 0x97, 0xB1, 0xC2, 0x25, 0x2A, 0x87, 0x65, 0x17, 0x3A, 0xCE, 0xF2, 0xD8, 0x92, 0xD6, 0xC2, 0x7B, 0xC7, 0xF3, 0x6E, + 0x32, 0xA9, 0xE9, 0x5F, 0x3F, 0x84, 0xCA, 0x33, 0x0E, 0x22, 0xAB, 0xF5, 0xBC, 0xC6, 0x5C, 0xB4, 0xD6, 0xB6, 0x67, + 0xAD, 0x0C, 0x44, 0x28, 0xAE, 0x58, 0x6E, 0x5E, 0x66, 0x08, 0x05, 0xB5, 0x9A, 0x64, 0xF4, 0x24, 0x57, 0x46, 0x6B, + 0xC4, 0x94, 0xF0, 0xF1, 0xFE, 0xCB, 0xC9, 0x63, 0x77, 0xB3, 0x68, 0xEF, 0xC3, 0xA6, 0xFD, 0xFA, 0x8C, 0xB7, 0x6B, + 0x67, 0x1D, 0x94, 0x7E, 0x74, 0xFF, 0x00, 0x34, 0xA7, 0xA0, 0xA5, 0x84, 0xC1, 0x61, 0x38, 0x66, 0x83, 0xC7, 0x66, + 0x0F, 0x9A, 0xB3, 0xD9, 0x1E, 0x84, 0x2B, 0xCA, 0xB3, 0xE5, 0x86, 0xC5, 0x8B, 0x4B, 0x47, 0xBA, 0x93, 0xED, 0x37, + 0x3F, 0x2C, 0x63, 0x90, 0x0D, 0x79, 0xB8, 0x2C, 0x25, 0x7C, 0xF6, 0xBB, 0xCD, 0x33, 0x47, 0xCB, 0x46, 0x3A, 0xA4, + 0xCF, 0x4E, 0x35, 0xD5, 0x25, 0xC9, 0x0D, 0xCF, 0xFF, 0xD9, +}; +static constexpr size_t JPEG_96X96_SIZE = sizeof(JPEG_96X96); + +} // namespace esphome::benchmarks diff --git a/tests/component_tests/adc/test_adc_defaults.yaml b/tests/component_tests/adc/test_adc_defaults.yaml new file mode 100644 index 0000000000..1bee400e62 --- /dev/null +++ b/tests/component_tests/adc/test_adc_defaults.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: adc + pin: GPIO36 + name: ADC Defaults + id: adc_defaults + + - platform: adc + pin: GPIO39 + name: ADC Custom + id: adc_custom + raw: true + attenuation: 6db + sampling_mode: max + samples: 4 diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py index a6d86f0305..3f1a4ae350 100644 --- a/tests/component_tests/adc/test_adc_sensor.py +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -30,3 +30,19 @@ def test_adc_regular_pin_is_not_deprecated( assert "adc_voltage->set_is_temperature();" not in main_cpp assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 + + +def test_adc_default_setters_are_not_emitted( + generate_main: Callable[[str | Path], str], +) -> None: + """Raw, attenuation and sampling mode matching the C++ initializers are skipped.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_defaults.yaml") + + assert "adc_defaults->set_output_raw(" not in main_cpp + assert "adc_defaults->set_attenuation(" not in main_cpp + assert "adc_defaults->set_sampling_mode(" not in main_cpp + assert "adc_defaults->set_sample_count(" not in main_cpp + assert "adc_custom->set_output_raw(true);" in main_cpp + assert "adc_custom->set_attenuation(ADC_ATTEN_DB_6);" in main_cpp + assert "adc_custom->set_sampling_mode(adc::SamplingMode::MAX);" in main_cpp + assert "adc_custom->set_sample_count(4);" in main_cpp diff --git a/tests/component_tests/binary_sensor/test_multi_click.py b/tests/component_tests/binary_sensor/test_multi_click.py new file mode 100644 index 0000000000..6f2556735f --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.py @@ -0,0 +1,29 @@ +"""Tests for the multi click trigger codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_multi_click_timing_tables(generate_main: Callable[[str | Path], str]) -> None: + """Identical timings share one PROGMEM table; the default cooldown is not set.""" + main_cpp = generate_main( + "tests/component_tests/binary_sensor/test_multi_click.yaml" + ) + + tables = re.findall( + r"static constexpr binary_sensor::MultiClickTriggerEvent (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(tables) == 2 + single, long_press = tables + triggers = re.findall( + r"binary_sensor::MultiClickTrigger\((\w+), (\w+), (\d+)\);", main_cpp + ) + assert triggers == [ + ("button_a", single, "2"), + ("button_a", long_press, "1"), + ("button_b", single, "2"), + ] + assert main_cpp.count("set_invalid_cooldown(") == 1 + assert "set_invalid_cooldown(500);" in main_cpp diff --git a/tests/component_tests/binary_sensor/test_multi_click.yaml b/tests/component_tests/binary_sensor/test_multi_click.yaml new file mode 100644 index 0000000000..c18737ea3c --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.yaml @@ -0,0 +1,33 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +binary_sensor: + - platform: gpio + id: button_a + pin: GPIO32 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: a single + - timing: + - ON for at least 2s + invalid_cooldown: 500ms + then: + - logger.log: a long + - platform: gpio + id: button_b + pin: GPIO33 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: b single diff --git a/tests/component_tests/bq25186/__init__.py b/tests/component_tests/bq25186/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py new file mode 100644 index 0000000000..940bf5822f --- /dev/null +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -0,0 +1,137 @@ +"""Tests for the bq25186 register packer.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.bq25186 import ( + BQ25186_CONFIG_SCHEMA, + CONF_BATTERY_REGULATION_VOLTAGE, + CONF_CHARGE_ENABLED, + CONF_FAST_CHARGE_CURRENT, + CONF_MASK_TS_INTERRUPT, + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + CONF_WATCHDOG_15S_ENABLE, + CONFIG_SCHEMA, + REG_ICHG_CTRL, + REG_MASK_ID, + REG_SHIP_RST, + REG_VBAT_CTRL, + _check_watchdog, + pack_registers, +) +from esphome.const import CONF_UPDATE_INTERVAL, CONF_WATCHDOG +from esphome.types import ConfigType + +# Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the +# watchdog disabled (0x87 instead of 0x84) and MASK_ID carries only its +# configurable bits 7:4 (0x40 instead of 0x41 with the device id). +DEFAULT_REGISTERS = [0x46, 0x05, 0x24, 0xC6, 0x87, 0x4D, 0x11, 0x40, 0x00, 0x40] + + +def _packed(reg: int, config: ConfigType | None = None) -> int: + regs = pack_registers(BQ25186_CONFIG_SCHEMA(config or {})) + return regs[reg - REG_VBAT_CTRL] + + +def test_defaults_match_power_on_values() -> None: + assert pack_registers(BQ25186_CONFIG_SCHEMA({})) == DEFAULT_REGISTERS + + +def test_battery_regulation_voltage_code() -> None: + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.1V"}) == 0x3C + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "3.5V"}) == 0x00 + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.65V"}) == 0x73 + + +def test_fast_charge_current_code() -> None: + # 1 mA steps from 5 mA to 35 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "5mA"}) == 0x00 + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "20mA"}) == 0x0F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "35mA"}) == 0x1E + # 10 mA steps from 40 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "40mA"}) == 0x1F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "200mA"}) == 0x2F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "1A"}) == 0x7F + + +def test_charge_disabled_sets_bit_7() -> None: + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: False}) == 0x85 + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: True}) == 0x05 + + +def test_masked_registers_only_carry_configurable_bits() -> None: + config: ConfigType = { + CONF_PUSH_BUTTON_LONG_PRESS_ACTION: "shutdown_mode", + CONF_MASK_TS_INTERRUPT: True, + } + # SHIP_RST bits 7:5 are reset triggers + ship_rst = _packed(REG_SHIP_RST, config) + assert ship_rst == 0x19 + assert ship_rst & ~0x1F == 0 + # MASK_ID bits 3:0 are the device id + mask_id = _packed(REG_MASK_ID, config) + assert mask_id == 0xC0 + assert mask_id & ~0xF0 == 0 + + +@pytest.mark.parametrize( + "key, value", + [ + (CONF_BATTERY_REGULATION_VOLTAGE, "4.105V"), + (CONF_FAST_CHARGE_CURRENT, "36mA"), + (CONF_FAST_CHARGE_CURRENT, "39mA"), + (CONF_FAST_CHARGE_CURRENT, "49mA"), + (CONF_FAST_CHARGE_CURRENT, "5.5mA"), + (CONF_FAST_CHARGE_CURRENT, "34.6mA"), + (CONF_BATTERY_REGULATION_VOLTAGE, "4.2005V"), + ], +) +def test_values_between_steps_are_rejected(key: str, value: str) -> None: + with pytest.raises(cv.Invalid): + BQ25186_CONFIG_SCHEMA({key: value}) + + +def _watchdog_config(interval: str, options: ConfigType) -> ConfigType: + config = BQ25186_CONFIG_SCHEMA(options) + config[CONF_UPDATE_INTERVAL] = cv.update_interval(interval) + return config + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "160s_software_reset"}), + ("60s", {CONF_WATCHDOG: "160s_hardware_reset"}), + ("30s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG: "disabled"}), + ("10s", {CONF_WATCHDOG_15S_ENABLE: True}), + ], +) +def test_update_interval_within_watchdog_passes( + interval: str, options: ConfigType +) -> None: + _check_watchdog(_watchdog_config(interval, options)) + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("40s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG_15S_ENABLE: True}), + ("20s", {CONF_WATCHDOG: "160s_software_reset", CONF_WATCHDOG_15S_ENABLE: True}), + ("never", {CONF_WATCHDOG: "160s_software_reset"}), + ], +) +def test_update_interval_past_watchdog_is_rejected( + interval: str, options: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match=CONF_UPDATE_INTERVAL): + _check_watchdog(_watchdog_config(interval, options)) + + +def test_unconfigured_chip_keeps_its_power_on_watchdog() -> None: + config = {"configure_on_boot": False, "update_interval": "160s"} + with pytest.raises(cv.Invalid, match="shorter than 160 s"): + CONFIG_SCHEMA(config) + CONFIG_SCHEMA({**config, "update_interval": "60s"}) diff --git a/tests/component_tests/es7210/test_audio_adc.py b/tests/component_tests/es7210/test_audio_adc.py new file mode 100644 index 0000000000..12a51eed2c --- /dev/null +++ b/tests/component_tests/es7210/test_audio_adc.py @@ -0,0 +1,89 @@ +"""Tests for ES7210 audio ADC configuration validation.""" + +import pytest + +from esphome.components.es7210.audio_adc import ( + CONF_CHANNEL_GAINS, + CONF_TDM, + CONFIG_SCHEMA, +) +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_MIC_GAIN, KEY_VARIANT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_channel_gains_valid_config() -> None: + """Per-channel gains accept an AEC microphone and reference configuration.""" + config = CONFIG_SCHEMA({CONF_CHANNEL_GAINS: ["33db", "33db", "0db", "0db"]}) + + assert config[CONF_CHANNEL_GAINS] == [33.0, 33.0, 0.0, 0.0] + assert CONF_MIC_GAIN not in config + + +def test_omitted_gains_remain_absent() -> None: + """Code generation supplies the default when neither gain option is configured.""" + config = CONFIG_SCHEMA({}) + + assert CONF_MIC_GAIN not in config + assert CONF_CHANNEL_GAINS not in config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32S2]) +def test_tdm_rejected_on_unsupported_variants( + variant: str, set_core_config: SetCoreConfigCallable +) -> None: + """ESP32 and ESP32-S2 cannot receive TDM audio.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + with pytest.raises(cv.Invalid, match="ES7210 TDM mode is not available") as exc: + CONFIG_SCHEMA({CONF_TDM: True}) + + assert exc.value.path == [CONF_TDM] + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32S2]) +@pytest.mark.parametrize("tdm_config", [{}, {CONF_TDM: False}]) +def test_tdm_disabled_on_unsupported_variants( + variant: str, + tdm_config: dict[str, bool], + set_core_config: SetCoreConfigCallable, +) -> None: + """Unsupported variants still accept standard I2S mode.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + assert CONFIG_SCHEMA(tdm_config)[CONF_TDM] is False + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32S3, VARIANT_ESP32C3, VARIANT_ESP32P4]) +def test_tdm_accepted_on_supported_variants( + variant: str, set_core_config: SetCoreConfigCallable +) -> None: + """Variants with TDM hardware accept TDM mode.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + assert CONFIG_SCHEMA({CONF_TDM: True})[CONF_TDM] is True + + +@pytest.mark.parametrize("channel_count", [3, 5]) +def test_channel_gains_requires_four_channels(channel_count: int) -> None: + """Channel gains must provide one value for each ES7210 input.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_CHANNEL_GAINS: ["0db"] * channel_count}) + + +def test_channel_gains_and_mic_gain_are_mutually_exclusive() -> None: + """Uniform and per-channel gain configuration cannot be combined.""" + with pytest.raises(cv.Invalid, match="Cannot specify more than one"): + CONFIG_SCHEMA( + { + CONF_MIC_GAIN: "24db", + CONF_CHANNEL_GAINS: ["0db"] * 4, + } + ) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bb751dd677..bd8c099c09 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -31,6 +31,7 @@ from esphome.components.esp32 import ( _reconcile_vfs_fatfs_sdkconfig, _user_sdkconfig_wants_tls, allow_insecure_tls, + idf_version, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -981,6 +982,17 @@ def test_user_sdkconfig_wants_tls(options: dict[str, Any], wants_tls: bool) -> N assert _user_sdkconfig_wants_tls(options) is wants_tls +def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: + """IDF 6 compiles TLS out with CONFIG_MBEDTLS_TLS_ENABLED=n; IDF 5 has no + prompt for that and uses CONFIG_MBEDTLS_TLS_DISABLED=y instead.""" + if idf_version() >= cv.Version(6, 0, 0): + value = sdkconfig.get("CONFIG_MBEDTLS_TLS_ENABLED") + if isinstance(value, RawSdkconfigValue): + return value.value == "n" + return value is False + return sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + + @pytest.mark.parametrize( ("config_file", "tls_off", "ecp_off", "esp_tls_excluded"), [ @@ -1034,7 +1046,7 @@ def test_tls_disabled_sdkconfig( """TLS is compiled out unless a component or a raw sdkconfig option asks for it.""" generate_main(component_config_path(config_file)) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off + assert _tls_compiled_out(sdkconfig) is tls_off assert sdkconfig.get("CONFIG_MBEDTLS_ECP_C") is (False if ecp_off else None) assert ( "esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] @@ -2168,7 +2180,7 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( deterministic ECDSA are plain crypto, and OpenThread keeps those two.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert _tls_compiled_out(sdkconfig) # require_mbedtls_ecp() keeps ECP for the SRP host key while TLS is off assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) @@ -2185,7 +2197,7 @@ def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( deterministic ECDSA.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert _tls_compiled_out(sdkconfig) assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) diff --git a/tests/component_tests/esp32_power_management/__init__.py b/tests/component_tests/esp32_power_management/__init__.py new file mode 100644 index 0000000000..a543aec300 --- /dev/null +++ b/tests/component_tests/esp32_power_management/__init__.py @@ -0,0 +1 @@ +"""Tests for the esp32_power_management component.""" diff --git a/tests/component_tests/esp32_power_management/test_init.py b/tests/component_tests/esp32_power_management/test_init.py new file mode 100644 index 0000000000..32665060b7 --- /dev/null +++ b/tests/component_tests/esp32_power_management/test_init.py @@ -0,0 +1,361 @@ +"""Tests for esp32_power_management configuration validation and sdkconfig wiring.""" + +from contextlib import nullcontext +import logging +from unittest import mock + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) +from esphome.components.esp32_power_management.power_management import ( + CONF_ENABLE_LIGHT_SLEEP, + CONF_IDLE_TIME_BEFORE_SLEEP, + CONF_MAX_FREQUENCY, + CONF_MIN_FREQUENCY, + CONF_POWER_DOWN_FLASH, + CONF_POWER_DOWN_PERIPHERALS, + CONFIG_SCHEMA, + _final_validate, + to_code, + valid_min_frequencies, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_POLL_PERIOD +from esphome.const import CONF_OPENTHREAD, KEY_FRAMEWORK_VERSION, PlatformFramework +from esphome.core import CORE, TimePeriodMilliseconds +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +LIGHT_SLEEP = {CONF_ENABLE_LIGHT_SLEEP: True} + + +def _set_variant( + set_core_config: SetCoreConfigCallable, + variant: str, + /, + *, + full_config: ConfigType | None = None, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: variant}, + full_config=full_config, + ) + + +@pytest.mark.parametrize( + ("variant", "config", "expectation"), + [ + pytest.param( + VARIANT_ESP32, + {CONF_MAX_FREQUENCY: "96MHZ"}, + pytest.raises(cv.Invalid, match="not a valid CPU frequency"), + id="max_invalid_for_variant", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_peripherals_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_FLASH: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_flash_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="only available on"), + id="power_down_peripherals_unsupported_variant", + ), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + nullcontext(), + id="power_down_peripherals_top_pd_variant", + ), + # The ESP32 keeps its reference clock divider at 10 or more + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "8MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_esp32_floor", + ), + # 26 MHz crystal modules exist, so 13 MHz is reachable on an ESP32 + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "13MHZ"}, + nullcontext(), + id="min_frequency_esp32_26mhz_crystal", + ), + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "80MHZ"}, + nullcontext(), + id="min_frequency_pll", + ), + # The ESP32-H2 runs from a 32 MHz crystal, so 8 MHz divides it and 10 MHz does not + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "8MHZ"}, + nullcontext(), + id="min_frequency_h2_crystal_divider", + ), + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "10MHZ"}, + pytest.raises(cv.Invalid, match="must divide the crystal"), + id="min_frequency_h2_not_a_divider", + ), + pytest.param( + VARIANT_ESP32C6, + {CONF_MIN_FREQUENCY: "1MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_reference_clock_floor", + ), + ], +) +def test_schema_validation( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expectation, +) -> None: + _set_variant(set_core_config, variant) + with expectation: + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + ("variant", "expected"), + [ + pytest.param( + VARIANT_ESP32, [10, 12, 13, 20, 24, 26, 40, 80, 160, 240], id="esp32" + ), + pytest.param(VARIANT_ESP32H2, [2, 4, 8, 16, 32, 48, 64, 96], id="esp32h2"), + pytest.param( + VARIANT_ESP32C6, [2, 4, 5, 8, 10, 20, 40, 80, 120, 160], id="esp32c6" + ), + ], +) +def test_valid_min_frequencies(variant: str, expected: list[int]) -> None: + assert valid_min_frequencies(variant) == expected + + +def test_frequencies_are_kept_in_mhz(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32) + config = CONFIG_SCHEMA({CONF_MAX_FREQUENCY: "160MHZ", CONF_MIN_FREQUENCY: 40e6}) + assert config[CONF_MAX_FREQUENCY] == 160 + assert config[CONF_MIN_FREQUENCY] == 40 + assert config[CONF_IDLE_TIME_BEFORE_SLEEP] == 3 + + +@pytest.mark.parametrize( + ("variant", "config", "expected"), + [ + pytest.param(VARIANT_ESP32H2, LIGHT_SLEEP, True, id="top_pd_light_sleep"), + pytest.param(VARIANT_ESP32, LIGHT_SLEEP, False, id="no_top_pd_light_sleep"), + pytest.param(VARIANT_ESP32H2, {}, False, id="no_light_sleep"), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: False}, + False, + id="explicit_value_kept", + ), + ], +) +def test_power_down_peripherals_default( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expected: bool, +) -> None: + _set_variant(set_core_config, variant) + assert CONFIG_SCHEMA(config)[CONF_POWER_DOWN_PERIPHERALS] is expected + + +def _final_validate_config( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + full_config: ConfigType | None = None, + boot_frequency: str = "160MHZ", +) -> None: + """Validate config, then run _final_validate against the given full config.""" + full_config = {"esp32": {"cpu_frequency": boot_frequency}} | (full_config or {}) + _set_variant(set_core_config, VARIANT_ESP32, full_config=full_config) + _final_validate(CONFIG_SCHEMA(config)) + + +def test_rejects_multiple_esp32_power_management_instances( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="Only one esp32_power_management instance"): + _final_validate_config( + set_core_config, + {}, + {"power_management": [{"platform": "esp32_power_management"}] * 2}, + ) + + +@pytest.mark.parametrize( + ("max_frequency", "boot_frequency", "expected"), + [ + pytest.param("160MHZ", "240MHZ", "downclocked to 160MHZ", id="boot_higher"), + pytest.param("240MHZ", "80MHZ", "may run as high as 240MHZ", id="boot_lower"), + pytest.param("160MHZ", "160MHZ", "", id="equal"), + ], +) +def test_boot_frequency_log( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + max_frequency: str, + boot_frequency: str, + expected: str, +) -> None: + caplog.set_level(logging.INFO) + _final_validate_config( + set_core_config, + {CONF_MAX_FREQUENCY: max_frequency}, + boot_frequency=boot_frequency, + ) + assert expected in caplog.text + assert (caplog.text == "") is (expected == "") + + +@pytest.mark.parametrize( + ("config", "boot_frequency", "expectation"), + [ + pytest.param( + {CONF_MAX_FREQUENCY: "80MHZ", CONF_MIN_FREQUENCY: "160MHZ"}, + "240MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_max", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "160MHZ"}, + "80MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_boot_frequency", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "80MHZ"}, + "80MHZ", + nullcontext(), + id="min_equal_to_boot_frequency", + ), + ], +) +def test_min_frequency_against_effective_max( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + boot_frequency: str, + expectation, +) -> None: + with expectation: + _final_validate_config(set_core_config, config, boot_frequency=boot_frequency) + + +def test_rejects_power_down_flash_with_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="not allowed when the device has PSRAM"): + _final_validate_config( + set_core_config, + LIGHT_SLEEP | {CONF_POWER_DOWN_FLASH: True}, + {"psram": {"mode": "quad"}}, + ) + + +@pytest.fixture +def sdkconfig_calls() -> dict[str, object]: + """Run to_code() without codegen and collect the sdkconfig options it sets.""" + calls: dict[str, object] = {} + with ( + mock.patch( + "esphome.components.esp32_power_management.power_management.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.power_management." + "register_power_management", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.add_idf_sdkconfig_option", + side_effect=calls.__setitem__, + ), + ): + yield calls + + +SLEEPY_MTD = { + CONF_OPENTHREAD: { + CONF_DEVICE_TYPE: "MTD", + CONF_POLL_PERIOD: TimePeriodMilliseconds(milliseconds=1000), + } +} +NOT_SET = object() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("core_config", "light_sleep", "key", "expected"), + [ + pytest.param(SLEEPY_MTD, True, "CONFIG_LWIP_ND6", False, id="nd6_sleepy_mtd"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_LWIP_ND6", + NOT_SET, + id="nd6_ftd", + ), + pytest.param({}, True, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_no_openthread"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_openthread", + ), + pytest.param( + {"zigbee": {"id": "zigbee_id"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_zigbee", + ), + pytest.param( + {}, True, "CONFIG_IEEE802154_SLEEP_ENABLE", NOT_SET, id="radio_sleep_none" + ), + pytest.param( + SLEEPY_MTD, False, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_light_sleep_off" + ), + pytest.param( + SLEEPY_MTD, + False, + "CONFIG_IEEE802154_SLEEP_ENABLE", + NOT_SET, + id="radio_sleep_light_sleep_off", + ), + ], +) +async def test_to_code_sdkconfig( + set_core_config: SetCoreConfigCallable, + sdkconfig_calls: dict[str, object], + core_config: ConfigType, + light_sleep: bool, + key: str, + expected: object, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32H2) + CORE.config = core_config + await to_code(CONFIG_SCHEMA({CONF_ENABLE_LIGHT_SLEEP: light_sleep})) + assert sdkconfig_calls.get(key, NOT_SET) is expected diff --git a/tests/component_tests/ethernet/config/rmii_custom.yaml b/tests/component_tests/ethernet/config/rmii_custom.yaml new file mode 100644 index 0000000000..291753592c --- /dev/null +++ b/tests/component_tests/ethernet/config/rmii_custom.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + type: LAN8720 + mdc_pin: GPIO4 + mdio_pin: GPIO5 + phy_addr: 1 + clk: + mode: CLK_OUT + pin: GPIO17 diff --git a/tests/component_tests/ethernet/config/rmii_defaults.yaml b/tests/component_tests/ethernet/config/rmii_defaults.yaml new file mode 100644 index 0000000000..7f39b31dfd --- /dev/null +++ b/tests/component_tests/ethernet/config/rmii_defaults.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + type: LAN8720 + mdc_pin: GPIO23 + mdio_pin: GPIO18 + clk: + mode: CLK_EXT_IN + pin: GPIO0 diff --git a/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml b/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml new file mode 100644 index 0000000000..3f21fcd2c6 --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO4 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/test_default_setters.py b/tests/component_tests/ethernet/test_default_setters.py new file mode 100644 index 0000000000..4658e8a4fb --- /dev/null +++ b/tests/component_tests/ethernet/test_default_setters.py @@ -0,0 +1,49 @@ +"""Ethernet setters are skipped when the config matches the C++ initializers.""" + +from collections.abc import Callable +from pathlib import Path + +_MDIO_SETTERS = ("set_phy_addr(", "set_mdc_pin(", "set_mdio_pin(") + + +def test_rmii_defaults_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rmii_defaults.yaml")) + + for setter in (*_MDIO_SETTERS, "set_clk_mode(", "set_clk_pin("): + assert setter not in main_cpp + + +def test_rmii_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rmii_custom.yaml")) + + assert "set_phy_addr(1);" in main_cpp + assert "set_mdc_pin(4);" in main_cpp + assert "set_mdio_pin(5);" in main_cpp + assert "set_clk_mode(::EMAC_CLK_OUT);" in main_cpp + assert "set_clk_pin(17);" in main_cpp + + +def test_spi2_interface_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ESP32-S3 defaults to spi2, which is the C++ initializer.""" + main_cpp = generate_main(component_config_path("spi_s3_own_bus.yaml")) + + assert "set_interface(" not in main_cpp + + +def test_spi3_interface_is_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Classic ESP32 defaults to spi3, so the setter is still needed.""" + main_cpp = generate_main(component_config_path("spi_own_bus.yaml")) + + assert "set_interface(::SPI3_HOST);" in main_cpp diff --git a/tests/component_tests/fingerprint_grow/__init__.py b/tests/component_tests/fingerprint_grow/__init__.py new file mode 100644 index 0000000000..42c5511598 --- /dev/null +++ b/tests/component_tests/fingerprint_grow/__init__.py @@ -0,0 +1 @@ +"""Tests for the fingerprint_grow component.""" diff --git a/tests/component_tests/fingerprint_grow/test_init.py b/tests/component_tests/fingerprint_grow/test_init.py new file mode 100644 index 0000000000..3df54f8847 --- /dev/null +++ b/tests/component_tests/fingerprint_grow/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the fingerprint_grow component schema.""" + +import pytest + +from esphome.components.fingerprint_grow import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.yaml_util import Sensitive +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("field", ["password", "new_password"]) +def test_password_is_sensitive( + field: str, set_core_config: SetCoreConfigCallable +) -> None: + """Numeric sensor passwords are tagged for redaction and keep their value.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({field: 305419896}) + assert isinstance(config[field], Sensitive) + assert config[field] == 305419896 diff --git a/tests/component_tests/gpio/test_gpio_switch.py b/tests/component_tests/gpio/test_gpio_switch.py new file mode 100644 index 0000000000..e62f11fd09 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch.py @@ -0,0 +1,33 @@ +"""Tests for the GPIO switch component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_gpio_switch_interlock_group_shares_one_table( + generate_main: Callable[[str | Path], str], +) -> None: + """Every member of an interlock group points at the same flash table.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + tables = re.findall(r"(gpio_interlock\w*)\[\] PROGMEM = \{([^}]*)\};", main_cpp) + assert tables == [("gpio_interlock", "sw_a, sw_b, sw_c")] + for sw in ("sw_a", "sw_b", "sw_c"): + assert f"{sw}->set_interlock(gpio_interlock, 3);" in main_cpp + + +def test_gpio_switch_interlock_with_only_itself_has_no_table( + generate_main: Callable[[str | Path], str], +) -> None: + """A switch interlocked only with itself gets no table and no set_interlock.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + assert "sw_self->set_interlock(" not in main_cpp + assert "sw_self->set_interlock_wait_time(0);" in main_cpp diff --git a/tests/component_tests/gpio/test_gpio_switch_interlock.yaml b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml new file mode 100644 index 0000000000..a3da64388f --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: esp32dev + +switch: + - platform: gpio + pin: 12 + id: sw_a + interlock: [sw_b, sw_c] + - platform: gpio + pin: 13 + id: sw_b + interlock: [sw_a, sw_c] + - platform: gpio + pin: 14 + id: sw_c + interlock: [sw_a, sw_b] + - platform: gpio + pin: 15 + id: sw_self + interlock: [sw_self] diff --git a/tests/component_tests/lis3dh/__init__.py b/tests/component_tests/lis3dh/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/lis3dh/test_motion.py b/tests/component_tests/lis3dh/test_motion.py new file mode 100644 index 0000000000..1d86761d4c --- /dev/null +++ b/tests/component_tests/lis3dh/test_motion.py @@ -0,0 +1,147 @@ +"""Tests for the lis3dh config cross-checks and interrupt threshold maths.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lis3dh.motion import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_DURATION, + CONF_HIGH_PASS_FILTER, + CONF_INTERRUPT, + CONF_OPERATING_MODE, + CONF_THRESHOLD, + _duration_register, + _threshold_register, + _validate_interrupt, + _validate_odr_mode, +) +import esphome.config_validation as cv +from esphome.core import TimePeriod + + +def _interrupt_config( + range_key: str, + odr_key: str, + threshold_g: float, + duration_ms: int, + high_pass: bool = True, +) -> dict: + return { + CONF_ACCELEROMETER_RANGE: range_key, + CONF_ACCELEROMETER_ODR: odr_key, + CONF_INTERRUPT: { + CONF_THRESHOLD: threshold_g, + CONF_DURATION: TimePeriod(milliseconds=duration_ms), + CONF_HIGH_PASS_FILTER: high_pass, + }, + } + + +@pytest.mark.parametrize("mode", ["NORMAL", "HIGH_RESOLUTION"]) +def test_1620hz_needs_low_power(mode: str) -> None: + """1620Hz exists only in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: mode} + with pytest.raises(cv.Invalid, match="only available with"): + _validate_odr_mode(config) + + +def test_1344hz_is_rejected_in_low_power() -> None: + """The 1344Hz code runs at 5376Hz in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1344HZ", CONF_OPERATING_MODE: "LOW_POWER"} + with pytest.raises(cv.Invalid, match="not available with"): + _validate_odr_mode(config) + + +def test_1620hz_low_power_is_accepted() -> None: + """The one valid 1620Hz combination passes through unchanged.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: "LOW_POWER"} + assert _validate_odr_mode(config) is config + + +@pytest.mark.parametrize( + ("threshold_g", "range_key", "expected"), + [ + (0.0, "2G", 1), + (0.05, "16G", 1), + (1.1, "2G", 69), + (0.2, "2G", 13), + (2.032, "2G", 127), + (23.622, "16G", 127), + ], +) +def test_threshold_register(threshold_g: float, range_key: str, expected: int) -> None: + """INT1_THS is the nearest step and never zero.""" + assert _threshold_register(threshold_g, range_key) == expected + + +@pytest.mark.parametrize( + ("threshold_g", "range_key"), + [(2.04, "2G"), (3.0, "2G"), (4.07, "4G"), (23.7, "16G")], +) +def test_threshold_above_range_is_rejected(threshold_g: float, range_key: str) -> None: + """A threshold the range cannot represent would never fire, so it fails validation.""" + config = _interrupt_config(range_key, "100HZ", threshold_g, 0) + with pytest.raises(cv.Invalid, match="'threshold' can be at most"): + _validate_interrupt(config) + + +def test_threshold_within_range_is_accepted() -> None: + """The largest representable threshold is still valid.""" + config = _interrupt_config("2G", "100HZ", 2.032, 0) + assert _validate_interrupt(config) is config + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key", "expected"), + [ + (0, "100HZ", 0), + (20, "100HZ", 2), + (1, "1620HZ", 2), + (10000, "1HZ", 10), + (4, "100HZ", 0), + ], +) +def test_duration_register(duration_ms: int, odr_key: str, expected: int) -> None: + """INT1_DURATION is the nearest number of samples at the data rate.""" + assert _duration_register(duration_ms, odr_key) == expected + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key"), [(1280, "100HZ"), (128000, "1HZ"), (79, "1620HZ")] +) +def test_duration_above_register_is_rejected(duration_ms: int, odr_key: str) -> None: + """A duration that needs more than 127 samples fails validation.""" + config = _interrupt_config("2G", odr_key, 1.1, duration_ms) + with pytest.raises(cv.Invalid, match="'duration' can be at most"): + _validate_interrupt(config) + + +def test_duration_at_register_limit_is_accepted() -> None: + """127 samples is the largest INT1_DURATION value.""" + config = _interrupt_config("2G", "100HZ", 1.1, 1270) + assert _validate_interrupt(config) is config + + +def test_sub_gravity_threshold_without_filter_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Gravity alone can hold the pad asserted below 1g unless the high-pass filter is on.""" + config = _interrupt_config("2G", "100HZ", 0.5, 0, high_pass=False) + with caplog.at_level(logging.WARNING): + assert _validate_interrupt(config) is config + assert "high_pass_filter: true" in caplog.text + + +@pytest.mark.parametrize(("threshold_g", "high_pass"), [(1.1, False), (0.5, True)]) +def test_threshold_without_gravity_problem_is_silent( + caplog: pytest.LogCaptureFixture, threshold_g: float, high_pass: bool +) -> None: + """Above 1g, or with the filter on, no warning is logged.""" + config = _interrupt_config("2G", "100HZ", threshold_g, 0, high_pass=high_pass) + with caplog.at_level(logging.WARNING): + _validate_interrupt(config) + assert caplog.text == "" diff --git a/tests/component_tests/lvgl/config/color_picker.yaml b/tests/component_tests/lvgl/config/color_picker.yaml new file mode 100644 index 0000000000..edff494234 --- /dev/null +++ b/tests/component_tests/lvgl/config/color_picker.yaml @@ -0,0 +1,133 @@ +esphome: + name: test-color-picker + +esp32: + board: lolin_c3_mini + +logger: + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +color: + - id: picker_colour + hex: "3366CC" + +lvgl: + style_definitions: + - id: picker_style + bg_opa: cover + + widgets: + # Sized from the text font + - color_picker: + id: picker_content + width: SIZE_CONTENT + + # Fixed width, colour given as a hex literal + - color_picker: + id: picker_fixed + width: 120 + color: 0xFF8800 + + # Styling the sliders the widget is built from + - color_picker: + id: picker_styled + width: 100 + items: + bg_opa: 80% + pressed: + border_width: 2 + knob: + bg_color: 0x112233 + radius: 4 + styles: [picker_style] + # One value for six sliders: it must not be generated once per slider. The body + # is more than an expression so that it stays a lambda rather than being folded + # into a constant. + border_width: !lambda |- + int width = 3; + return width; + pressed: + styles: [picker_style] + # Belongs to the widget as a whole, so it is reported and ignored here + ext_click_area: 8 + + # Only the red, green and blue sliders, with a margin making them easier to press. + # The scrolling options belong to the widget's own object, not to the sliders. + - color_picker: + id: picker_rgb + width: 100 + sliders: rgb + ext_click_area: 12 + scrollbar_mode: "OFF" + + # Black must not be mistaken for no colour at all + - color_picker: + id: picker_black + width: 100 + color: 0x000000 + + # The knob background arrives through a named style rather than directly + - color_picker: + id: picker_knob_style + width: 100 + knob: + styles: [picker_style] + + # Only the hue, saturation and brightness sliders, styled + - color_picker: + id: picker_hsv + width: 100 + sliders: [hsv] + knob: + radius: 2 + + # A single slider, for picking a hue with the brightness controlled elsewhere + - color_picker: + id: picker_hue + width: 100 + sliders: hue + + # A colour wheel in place of the hue and saturation sliders + - color_picker: + id: picker_wheel + width: 100 + wheel: true + + # Colour given as the id of a colour defined above + - color_picker: + id: picker_colour_id + width: 50% + color: picker_colour + on_value: + - logger.log: + format: "picked %d,%d,%d" + args: [x.r, x.g, x.b] + on_release: + - logger.log: released + on_update: + - logger.log: updated + +script: + - id: update_picker + then: + - lvgl.color_picker.update: + id: picker_fixed + color: 0x00FF00 + - lvgl.color_picker.update: + id: picker_hue + color: !lambda return lv_color_hex(0x123456); diff --git a/tests/component_tests/lvgl/config/color_picker_theme.yaml b/tests/component_tests/lvgl/config/color_picker_theme.yaml new file mode 100644 index 0000000000..47ae267cae --- /dev/null +++ b/tests/component_tests/lvgl/config/color_picker_theme.yaml @@ -0,0 +1,48 @@ +esphome: + name: test-color-picker + +esp32: + board: lolin_c3_mini + +logger: + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + style_definitions: + # A widget property in a style is dropped, so it is reported + - id: clickable_style + ext_click_area: 10 + + theme: + # A themed knob colour must not be covered by the knob tinting + color_picker: + knob: + bg_color: 0x112233 + # Widget properties in a theme are dropped too, at any level + button: + scrollbar_mode: "OFF" + pressed: + ext_click_area: 4 + + widgets: + - color_picker: + id: themed_picker + width: 100 + - button: + id: themed_button + styles: clickable_style diff --git a/tests/component_tests/lvgl/config/named_styles.yaml b/tests/component_tests/lvgl/config/named_styles.yaml new file mode 100644 index 0000000000..2adc16ddf4 --- /dev/null +++ b/tests/component_tests/lvgl/config/named_styles.yaml @@ -0,0 +1,47 @@ +esphome: + name: test-named-styles + +esp32: + board: lolin_c3_mini + +logger: + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + style_definitions: + - id: chunky + bg_opa: cover + border_width: 2 + + widgets: + # A plain widget, so the selector each named style is added with is the one LVGL is + # given directly, with no compound widget in the way. + - slider: + id: plain_slider + width: 100 + # No part and no state: the selector is a bare zero. + styles: [chunky] + knob: + # A part on its own. + styles: [chunky] + pressed: + # A part combined with a state, which has to survive the cast. + styles: [chunky] + pressed: + # A state on its own. + styles: [chunky] diff --git a/tests/component_tests/lvgl/test_color_picker.py b/tests/component_tests/lvgl/test_color_picker.py new file mode 100644 index 0000000000..1d094a62dc --- /dev/null +++ b/tests/component_tests/lvgl/test_color_picker.py @@ -0,0 +1,628 @@ +"""Tests for the LVGL color picker widget schema and code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest +from voluptuous import Schema + +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.widgets import _build_update_schema +from esphome.components.lvgl.widgets.color_picker import color_picker_spec +from esphome.config_validation import Invalid +from esphome.const import CONF_WIDTH + + +@pytest.fixture() +def update_schema() -> Schema: + """The schema behind `lvgl.color_picker.update`.""" + return _build_update_schema(color_picker_spec) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestColorPickerValidation: + """Test the widget's own schema options.""" + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + return container_schema(color_picker_spec) + + @pytest.mark.parametrize("width", [100, "50%", "SIZE_CONTENT"]) + def test_width_accepted( + self, schema: Callable[[dict], dict], width: int | str + ) -> None: + assert schema({"width": width}) is not None + + def test_width_defaults_to_size_content( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({})[CONF_WIDTH] == "LV_SIZE_CONTENT" + + def test_height_is_rejected(self, schema: Callable[[dict], dict]) -> None: + """The widget is square, so a height of its own makes no sense.""" + with pytest.raises(Invalid, match="Height will be set to the same as width"): + schema({"width": 100, "height": 100}) + + def test_color_is_optional(self, schema: Callable[[dict], dict]) -> None: + assert "color" not in schema({"width": 100}) + + def test_on_value_is_accepted(self, schema: Callable[[dict], dict]) -> None: + """The trigger is only offered if the widget declares it has a value.""" + result = schema({"width": 100, "on_value": [{"logger.log": "picked"}]}) + + assert "on_value" in result + + def test_value_argument_is_a_color(self) -> None: + """`x` must be an esphome Color, so that lambdas can use x.r/x.g/x.b. + + esphome::Color converts to lv_color_t implicitly, so declaring the wrong + one of the two still compiles but gives lambdas the LVGL struct, whose + members are named differently. + """ + (arg_type,) = color_picker_spec.w_type.get_arg_type() + + assert str(arg_type) == "Color" + + def test_update_action_sets_the_colour(self, update_schema: Schema) -> None: + """The colour is the one thing an update action is for.""" + assert update_schema({"id": "picker", "color": 0xFF8800}) is not None + + def test_update_action_does_not_require_width(self, update_schema: Schema) -> None: + """Width is only needed when the widget is created, unlike in the widget schema.""" + assert CONF_WIDTH not in update_schema({"id": "picker", "color": 0xFF8800}) + + def test_slider_parts_are_declared(self) -> None: + """`items` and `knob` stand for the sliders the widget is built from.""" + assert color_picker_spec.parts == ("main", "items", "knob") + + @pytest.mark.parametrize("part", ["items", "knob"]) + def test_slider_parts_are_accepted( + self, schema: Callable[[dict], dict], part: str + ) -> None: + assert schema({"width": 100, part: {"radius": 4}}) is not None + + +class TestColorPickerSliderChoice: + """Test choosing which of the six sliders the widget is built from.""" + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + return container_schema(color_picker_spec) + + def test_all_sliders_by_default(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100})["sliders"] == [ + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", + ] + + @pytest.mark.parametrize( + ("configured", "expected"), + [ + ("rgb", ["red", "green", "blue"]), + ("hsv", ["hue", "saturation", "brightness"]), + # Both spellings of the third HSV component are accepted. + ("hsb", ["hue", "saturation", "brightness"]), + (["hue", "saturation"], ["hue", "saturation"]), + (["hue"], ["hue"]), + # A group can be mixed with single sliders. + (["rgb", "hue"], ["hue", "red", "green", "blue"]), + ], + ) + def test_groups_are_expanded( + self, + schema: Callable[[dict], dict], + configured: str | list[str], + expected: list[str], + ) -> None: + assert schema({"width": 100, "sliders": configured})["sliders"] == expected + + def test_order_follows_the_widget_not_the_config( + self, schema: Callable[[dict], dict] + ) -> None: + """The layout is fixed, so the order they are listed in makes no difference.""" + assert schema({"width": 100, "sliders": ["blue", "red"]})["sliders"] == [ + "red", + "blue", + ] + + def test_repeats_are_dropped(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100, "sliders": ["red", "rgb"]})["sliders"] == [ + "red", + "green", + "blue", + ] + + def test_empty_list_is_rejected(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="At least one slider is required"): + schema({"width": 100, "sliders": []}) + + def test_unknown_slider_is_rejected(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="Unknown value 'cyan'"): + schema({"width": 100, "sliders": ["cyan"]}) + + def test_sliders_cannot_be_updated(self, update_schema: Schema) -> None: + """The layout is built from them, so they cannot change after creation.""" + with pytest.raises(Invalid, match="sliders"): + update_schema({"id": "picker", "sliders": "rgb"}) + + +class TestColorPickerWheel: + """Test the `wheel` option, which replaces the hue and saturation sliders, and the RGB + sliders besides, with a circular hue/saturation picker. + + The exclusion of these sliders happens in `ColorPickerType.validate()`, which runs + before the schema itself (see `WIDGET_TYPES` validation in schemas.py), so these tests + reproduce that order rather than using the `container_schema` fixture alone. + """ + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + container = container_schema(color_picker_spec) + + def _validate(value: dict) -> dict: + return container(color_picker_spec.validate(value)) + + return _validate + + def test_wheel_defaults_to_false(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100})["wheel"] is False + + def test_wheel_must_be_a_boolean(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="boolean"): + schema({"width": 100, "wheel": "round"}) + + def test_wheel_leaves_only_brightness_by_default( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({"width": 100, "wheel": True})["sliders"] == ["brightness"] + + def test_wheel_is_compatible_with_an_explicit_slider_list( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({"width": 100, "wheel": True, "sliders": "brightness"})[ + "sliders" + ] == ["brightness"] + + @pytest.mark.parametrize( + "sliders", + ["hue", "saturation", "red", "green", "blue", "rgb", ["hue", "red"], "hsv"], + ) + def test_wheel_conflicts_with_sliders_it_replaces( + self, schema: Callable[[dict], dict], sliders: str | list[str] + ) -> None: + with pytest.raises(Invalid, match="color wheel"): + schema({"width": 100, "wheel": True, "sliders": sliders}) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + +_SET_STYLE_RE = re.compile(r"lv_obj_set_style_(\w+)\((\w+)->obj, (.+?), \w+\);") + + +def _styles_for(main_cpp: str, var: str) -> dict[str, str]: + """Return {property: value} for every style set directly on a widget.""" + return { + m.group(1): m.group(3) + for m in _SET_STYLE_RE.finditer(main_cpp) + if m.group(2) == var + } + + +class TestColorPickerCodeGeneration: + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + def test_height_follows_width(self, main_cpp: str) -> None: + """The widget is square, so height is generated to match the given width.""" + for var, expected in ( + ("picker_content", "LV_SIZE_CONTENT"), + ("picker_fixed", "120"), + ("picker_colour_id", "lv_pct(50)"), + ): + styles = _styles_for(main_cpp, var) + assert styles["width"] == expected + assert styles["height"] == expected + + def test_color_is_applied(self, main_cpp: str) -> None: + assert "picker_fixed->set_color(" in main_cpp + assert "picker_colour_id->set_color(" in main_cpp + + @pytest.mark.parametrize("var", ["picker_content", "picker_rgb", "picker_wheel"]) + def test_color_is_omitted_when_not_configured( + self, main_cpp: str, var: str + ) -> None: + """Each of these can change saturation, so the grey it starts with is no trap.""" + assert f"{var}->set_color(" not in main_cpp + + def test_wheel_code_is_built_for_a_wheel(self, main_cpp: str) -> None: + from esphome.core import CORE + + assert "USE_LVGL_COLOR_PICKER_WHEEL" in {d.name for d in CORE.defines} + + def test_wheel_code_is_left_out_without_a_wheel( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Its gradients would otherwise take RAM in every build with a picker.""" + from esphome.core import CORE + + generate_main(component_config_path("color_picker_theme.yaml")) + + assert "USE_LVGL_COLOR_PICKER_WHEEL" not in {d.name for d in CORE.defines} + + def test_hue_only_picker_starts_saturated(self, main_cpp: str) -> None: + """With no way to change saturation, a grey start would leave it stuck at grey.""" + assert "picker_hue->set_color(lv_color_make(255, 0, 0));" in main_cpp + + def test_black_is_applied(self, main_cpp: str) -> None: + """A hex colour validates to a plain int, and black must not read as absent.""" + assert "picker_black->set_color(lv_color_make(0, 0, 0))" in main_cpp + + def test_trigger_uses_color_type(self, main_cpp: str) -> None: + """The on_value trigger is declared with the Color the widget reports.""" + assert "Trigger" in main_cpp + + def test_trigger_reads_the_current_color(self, main_cpp: str) -> None: + assert "picker_colour_id->get_color()" in main_cpp + + def test_update_action_generates_set_color(self, main_cpp: str) -> None: + """`lvgl.color_picker.update` used to fail outright on the missing width.""" + assert main_cpp.count("picker_fixed->set_color(") == 2 + + def test_update_action_accepts_a_lambda(self, main_cpp: str) -> None: + # A lambda that only returns an expression is generated as that expression. + assert "picker_hue->set_color(lv_color_hex(0x123456));" in main_cpp + + @pytest.mark.parametrize("message", ["released", "updated"]) + def test_other_triggers_are_generated(self, main_cpp: str, message: str) -> None: + assert f'"{message}"' in main_cpp + + def test_gradient_stops_are_raised_for_the_hue_bar(self, main_cpp: str) -> None: + """The hue bar fills seven stops of a fixed-size array. + + `gradient.py` raises the limit when a colour picker is in use, and the C++ + `static_assert` relies on that having happened. It works because the widget + records the use during validation, which runs first, but nothing in either + file says so, hence this. + """ + from esphome.components.lvgl.defines import get_defines + + assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "7" + + def test_gradient_stops_are_left_alone_without_a_picker( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """The other half of the same invariant: only a picker pays for the extra stops.""" + from esphome.components.lvgl.defines import get_defines + + # Any configuration without a colour picker in it will do. + generate_main(component_config_path("named_styles.yaml")) + + assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "2" + + def test_complex_gradients_are_enabled_for_a_wheel(self, main_cpp: str) -> None: + """The wheel is a conical and a radial gradient, which need this to render at all.""" + from esphome.components.lvgl.defines import get_defines + + assert get_defines()["LV_USE_DRAW_SW_COMPLEX_GRADIENTS"] == "1" + + def test_complex_gradients_are_left_alone_without_a_wheel( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + from esphome.components.lvgl.defines import get_defines + + generate_main(component_config_path("named_styles.yaml")) + + assert "LV_USE_DRAW_SW_COMPLEX_GRADIENTS" not in get_defines() + + +# The widget's own object is a plain container, so styles for the parts a slider has are +# applied to each of its sliders instead. +_SLIDER_STYLE_RE = re.compile( + r"lv_obj_set_style_(\w+)\((\w+)->get_slider\(LvColorPickerType::SLIDER_(\w+)\), " + r"(.+?), (\w+)\);" +) + +ALL_SLIDERS = {"HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"} + + +def _slider_style_values(main_cpp: str, var: str, prop: str) -> set[str]: + """Return every value one style property was set to across a widget's sliders.""" + return { + m.group(4) + for m in _SLIDER_STYLE_RE.finditer(main_cpp) + if m.group(2) == var and m.group(1) == prop and m.group(5) == "LV_PART_KNOB" + } + + +def _slider_styles(main_cpp: str, var: str) -> dict[tuple[str, str], set[str]]: + """Return {(property, selector): sliders it was applied to} for one widget.""" + found: dict[tuple[str, str], set[str]] = {} + for m in _SLIDER_STYLE_RE.finditer(main_cpp): + if m.group(2) == var: + found.setdefault((m.group(1), m.group(5)), set()).add(m.group(3)) + return found + + +class TestColorPickerSliderStyles: + @pytest.fixture() + def styles( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> dict[tuple[str, str], set[str]]: + main_cpp = generate_main(component_config_path("color_picker.yaml")) + return _slider_styles(main_cpp, "picker_styled") + + @pytest.mark.parametrize( + ("prop", "selector"), + [ + # `knob` keeps its name on the slider... + ("bg_color", "LV_PART_KNOB"), + ("radius", "LV_PART_KNOB"), + # ...while `items` becomes the slider's own body. + ("bg_opa", "LV_PART_MAIN"), + # A state without a part names only the state, as both are zero. + ("border_width", "LV_STATE_PRESSED"), + ], + ) + def test_style_reaches_every_slider( + self, styles: dict[tuple[str, str], set[str]], prop: str, selector: str + ) -> None: + assert styles[prop, selector] == ALL_SLIDERS + + def test_style_skips_sliders_the_widget_lacks( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """A style must not be applied to a slider that was never created.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + styles = _slider_styles(main_cpp, "picker_hsv") + + assert styles["radius", "LV_PART_KNOB"] == {"HUE", "SATURATION", "BRIGHTNESS"} + + @pytest.mark.parametrize( + "selector", + [ + # A part on its own needs no cast... + "LV_PART_KNOB", + # ...while a part combined with a state is cast to the type LVGL takes, which is + # wide enough for both. A state alone would not be. + ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ), + ], + ) + def test_named_style_reaches_every_slider( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + selector: str, + ) -> None: + """A style listed under `styles:` is added to each slider, not to the container.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + added = re.findall( + r"lv_obj_add_style\(picker_styled->get_slider\(" + r"LvColorPickerType::SLIDER_(\w+)\), picker_style, (.+?)\);", + main_cpp, + ) + + assert {name for name, sel in added if sel == selector} == ALL_SLIDERS + + def test_a_lambda_is_generated_once_for_all_the_sliders( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """One YAML value must not become one C++ function per slider.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + # The source also appears once in the comment codegen writes above the lambda. + assert main_cpp.count("int border_width_VAR_ = []() -> int {") == 1 + + def test_the_same_lambda_reaches_every_slider( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Generating it once must not mean applying it once.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + styles = _slider_styles(main_cpp, "picker_styled") + + assert styles["border_width", "LV_PART_KNOB"] == ALL_SLIDERS + assert _slider_style_values(main_cpp, "picker_styled", "border_width") == { + "border_width_VAR_" + } + + def test_no_styles_are_left_on_the_widget_itself( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Setting these on the container would have no visible effect.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + own = _styles_for(main_cpp, "picker_styled") + + assert set(own) == {"width", "height"} + + @pytest.mark.parametrize( + "var", + [ + # The background is set on the knob directly... + "picker_styled", + # ...or through a named style, which the tint would still outrank. + "picker_knob_style", + ], + ) + def test_knob_tinting_is_disabled_by_a_knob_background( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + var: str, + ) -> None: + """The tint is a local style, so it would overwrite the configured colour.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + assert f"{var}->set_tint_knobs(false)" in main_cpp + + @pytest.mark.parametrize( + "var", ["picker_content", "picker_fixed", "picker_colour_id"] + ) + def test_knob_tinting_is_left_on_otherwise( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + var: str, + ) -> None: + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + assert f"{var}->set_tint_knobs(" not in main_cpp + + +class TestColorPickerObjectProperties: + """The widget's own object is never pressed, so touch settings go to the sliders.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + def test_ext_click_area_reaches_the_sliders(self, main_cpp: str) -> None: + """This is what makes the knobs easier to grab: it widens each slider's touch area.""" + found = re.findall( + r"lv_obj_set_ext_click_area\(picker_rgb->get_slider\(" + r"LvColorPickerType::SLIDER_(\w+)\), 12\);", + main_cpp, + ) + + assert set(found) == {"RED", "GREEN", "BLUE"} + + def test_ext_click_area_is_not_left_on_the_widget_itself( + self, main_cpp: str + ) -> None: + """The container has nothing clickable in it, so setting it there does nothing.""" + assert "lv_obj_set_ext_click_area(picker_rgb->obj" not in main_cpp + + def test_ext_click_area_under_a_part_is_reported(self, main_cpp: str) -> None: + """It takes no part, so LVGL cannot apply it to one; that must not pass silently.""" + from esphome.components.lvgl.defines import get_warnings + + assert any( + "'ext_click_area'" in w and "'knob'" in w and "'picker_styled'" in w + for w in get_warnings() + ), get_warnings() + + def test_scrollbar_mode_stays_on_the_widget_itself(self, main_cpp: str) -> None: + """Only the touch margin moves: a slider never scrolls, but the container can.""" + assert "lv_obj_set_scrollbar_mode(picker_rgb->obj, LV_SCROLLBAR_MODE_OFF)" in ( + main_cpp + ) + + def test_scrollbar_mode_does_not_reach_the_sliders(self, main_cpp: str) -> None: + assert "lv_obj_set_scrollbar_mode(picker_rgb->get_slider(" not in main_cpp + + +class TestColorPickerSliderConstruction: + """The chosen sliders are passed to the constructor, before the layout is built.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + @pytest.mark.parametrize( + ("var", "expected"), + [ + ("picker_rgb", ["RED", "GREEN", "BLUE"]), + ("picker_hsv", ["HUE", "SATURATION", "BRIGHTNESS"]), + ("picker_hue", ["HUE"]), + ( + "picker_fixed", + ["HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"], + ), + ], + ) + def test_constructor_names_the_chosen_sliders( + self, main_cpp: str, var: str, expected: list[str] + ) -> None: + flags = " | ".join( + f"LvColorPickerType::SLIDER_FLAG_{name}" for name in expected + ) + + assert f"new({var}) LvColorPickerType({flags}, false);" in main_cpp + + def test_constructor_passes_wheel_true(self, main_cpp: str) -> None: + assert ( + "new(picker_wheel) LvColorPickerType(" + "LvColorPickerType::SLIDER_FLAG_BRIGHTNESS, true);" + ) in main_cpp + + def test_constructor_passes_wheel_false_by_default(self, main_cpp: str) -> None: + assert "new(picker_hue) LvColorPickerType(" in main_cpp + assert "LvColorPickerType::SLIDER_FLAG_HUE, false);" in main_cpp + + +class TestColorPickerTheme: + """Settings that come from the theme or a style definition rather than the widget.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker_theme.yaml")) + + def test_theme_knob_color_turns_tinting_off(self, main_cpp: str) -> None: + """The tint is a local style, so it would overwrite the themed colour.""" + assert "themed_picker->set_tint_knobs(false)" in main_cpp + + @pytest.mark.parametrize( + "expected", + [ + ("'ext_click_area'", "style 'clickable_style'"), + ("'scrollbar_mode'", "the theme for 'button'"), + ("'ext_click_area'", "the theme for 'button' under 'pressed'"), + ], + ) + def test_widget_properties_in_styles_are_reported( + self, main_cpp: str, expected: tuple[str, str] + ) -> None: + """A style only holds style properties, so these would be dropped silently.""" + from esphome.components.lvgl.defines import get_warnings + + prop, place = expected + assert any(prop in w and f"in {place} " in w for w in get_warnings()), ( + get_warnings() + ) diff --git a/tests/component_tests/lvgl/test_named_styles.py b/tests/component_tests/lvgl/test_named_styles.py new file mode 100644 index 0000000000..0fba00cd3a --- /dev/null +++ b/tests/component_tests/lvgl/test_named_styles.py @@ -0,0 +1,57 @@ +"""Tests for the selector a named style is added to a widget with. + +A part and a state are combined into one value, but they live in different halves of it: +a state fits in 16 bits, while a part starts at bit 16. Casting the pair to the state type +would throw the part away and quietly move the style to the main part, so these pin the +type each selector is generated with. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _selectors(main_cpp: str, var: str, style: str) -> set[str]: + """Return every selector a named style was added to a widget with.""" + return set( + re.findall( + rf"lv_obj_add_style\({var}, {style}, (.+?)\);", + main_cpp, + ) + ) + + +class TestNamedStyleSelectors: + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("named_styles.yaml")) + + def test_every_selector_is_generated(self, main_cpp: str) -> None: + assert _selectors(main_cpp, "plain_slider", "chunky") == { + # Neither a part nor a state, which names the main part. + "LV_PART_MAIN", + # A state on its own, which needs no cast as it is the narrower half. + "LV_STATE_PRESSED", + # A part on its own. + "LV_PART_KNOB", + # A part with a state, cast to the type LVGL takes, which is wide enough for + # both. The state type alone would drop the part. + ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ), + } + + def test_a_combined_selector_is_not_narrowed_to_a_state( + self, main_cpp: str + ) -> None: + """`lv_state_t` is 16 bits, so it cannot hold a part.""" + assert "(lv_state_t)((int)LV_PART_KNOB" not in main_cpp diff --git a/tests/component_tests/nextion/__init__.py b/tests/component_tests/nextion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/nextion/config/custom_triggers.yaml b/tests/component_tests/nextion/config/custom_triggers.yaml new file mode 100644 index 0000000000..6391a8babe --- /dev/null +++ b/tests/component_tests/nextion/config/custom_triggers.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +display: + - platform: nextion + on_custom_text_sensor: + - then: + - lambda: 'ESP_LOGD("test", "%s=%s", key.c_str(), value.c_str());' + - then: + - delay: 1s + - lambda: 'ESP_LOGD("test", "%s=%s", key.c_str(), value.c_str());' diff --git a/tests/component_tests/nextion/test_custom_trigger_args.py b/tests/component_tests/nextion/test_custom_trigger_args.py new file mode 100644 index 0000000000..765b7ec786 --- /dev/null +++ b/tests/component_tests/nextion/test_custom_trigger_args.py @@ -0,0 +1,47 @@ +"""Tests for the argument types of the nextion custom-protocol triggers.""" + +from collections.abc import Callable +from pathlib import Path + +import esphome.codegen as cg +from esphome.components.nextion.display import _owning_args +from esphome.core import Lambda + +ARGS = [(cg.StringRef, "key"), (cg.bool_, "value")] +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +DEFERRING_ACTION = {"delay": "1s"} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + + +def test_synchronous_automation_keeps_string_ref() -> None: + assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION]}) == ARGS + + +def test_deferring_automation_gets_std_string() -> None: + assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}) == [ + (cg.std_string, "key"), + (cg.bool_, "value"), + ] + + +def test_nested_deferring_action_gets_std_string() -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + assert _owning_args(ARGS, {"then": actions})[0] == (cg.std_string, "key") + + +def test_all_string_refs_swapped() -> None: + args = [(cg.StringRef, "key"), (cg.StringRef, "value")] + assert _owning_args(args, {"then": [DEFERRING_ACTION]}) == [ + (cg.std_string, "key"), + (cg.std_string, "value"), + ] + + +def test_generated_automation_types( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("custom_triggers.yaml")) + + assert "Automation" in main_cpp + assert "Automation" in main_cpp diff --git a/tests/component_tests/noise/test_encryption_key.py b/tests/component_tests/noise/test_encryption_key.py index 2b79bd5464..3a895e25de 100644 --- a/tests/component_tests/noise/test_encryption_key.py +++ b/tests/component_tests/noise/test_encryption_key.py @@ -5,7 +5,11 @@ from __future__ import annotations import pytest from esphome import config_validation as cv -from esphome.components.noise import decode_encryption_key, validate_encryption_key +from esphome.components.noise import ( + decode_encryption_key, + generate_encryption_key, + validate_encryption_key, +) KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" @@ -42,3 +46,11 @@ def test_validate_encryption_key_rejects_all_zeros() -> None: key, so it never reaches a build.""" with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + + +def test_generate_encryption_key_passes_the_validator() -> None: + """A fresh key is 32 random bytes in the base64 form the yaml takes.""" + key = generate_encryption_key() + assert validate_encryption_key(key) == key + assert len(decode_encryption_key(key)) == 32 + assert key != generate_encryption_key() diff --git a/tests/component_tests/openthread/__init__.py b/tests/component_tests/openthread/__init__.py new file mode 100644 index 0000000000..d786022c12 --- /dev/null +++ b/tests/component_tests/openthread/__init__.py @@ -0,0 +1 @@ +"""Tests for the openthread component.""" diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py new file mode 100644 index 0000000000..7ba9bdd4bb --- /dev/null +++ b/tests/component_tests/openthread/test_init.py @@ -0,0 +1,39 @@ +"""Tests for the openthread component schema.""" + +import pytest + +from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6 +from esphome.components.openthread import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.core import HexInt +from esphome.yaml_util import Sensitive +from tests.component_tests.types import SetCoreConfigCallable + +NETWORK_KEY = 0xDFD34F0F05CAD978EC4E32B0413038FF +PSKC = 0xC23A76E98F1A6483639B1AC1271E2E27 +TLV = "0e080000000000010000000300001435060004001fffe0" + + +@pytest.mark.parametrize( + ("config", "field", "expected"), + [ + ({"network_key": hex(NETWORK_KEY)}, "network_key", NETWORK_KEY), + ({"network_key": hex(NETWORK_KEY), "pskc": hex(PSKC)}, "pskc", PSKC), + ({"tlv": TLV}, "tlv", TLV), + ], +) +def test_credentials_are_sensitive( + config: dict[str, str], + field: str, + expected: int | str, + set_core_config: SetCoreConfigCallable, +) -> None: + """Thread credentials are tagged for redaction without changing their value + or the hexadecimal integer type used to build sdkconfig options.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + validated = CONFIG_SCHEMA(config) + assert isinstance(validated[field], Sensitive) + assert validated[field] == expected + assert isinstance(validated[field], HexInt) == isinstance(expected, int) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 77db149923..39920ff436 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -30,7 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv @@ -215,6 +215,77 @@ def test_encryption_schema_allow_plaintext_upload() -> None: encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) +def test_encryption_old_key_schema() -> None: + """old_key is validated like key and is an ota only option.""" + conf = _encryption_schema({CONF_KEY: API_KEY, CONF_OLD_KEY: OTHER_KEY}) + assert conf[CONF_OLD_KEY] == OTHER_KEY + with pytest.raises(cv.Invalid, match="base64"): + _encryption_schema({CONF_OLD_KEY: "short"}) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_OLD_KEY: OTHER_KEY}) + + +def test_encryption_old_key_keeps_inherited_key() -> None: + """A bare block with old_key inherits the api key as the current one.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}) + ], + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_OLD_KEY] == OTHER_KEY + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_mismatch_between_merged_configs_rejected() -> None: + """Same-port blocks that pin different old keys raise like differing keys.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}), + _make_ota_config( + port=3232, + **{ + CONF_ENCRYPTION: { + CONF_OLD_KEY: "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=" + } + }, + ), + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="old_key is inconsistent"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_equal_to_key_rejected() -> None: + """An old_key that matches key is a leftover from a finished rotation.""" + full_conf = { + CONF_API: {}, + CONF_OTA: [ + _make_ota_config( + port=3232, + **{CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_OLD_KEY: API_KEY}}, + ) + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="'old_key' is the same as 'key'"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -225,7 +296,7 @@ def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: } token = fv.full_config.set(full_conf) try: - with pytest.raises(cv.Invalid, match="encryption is inconsistent"): + with pytest.raises(cv.Invalid, match="encryption key is inconsistent"): ota_esphome_final_validate({}) finally: fv.full_config.reset(token) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 418cab5ea1..fd7e9f1278 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -10,9 +10,11 @@ import pytest from esphome import bundle from esphome.components.packages import ( CONFIG_SCHEMA, + RemotePackageSource, _substitute_package_definition, _walk_packages, do_packages_pass, + get_remote_package_sources, merge_packages, resolve_packages, ) @@ -614,6 +616,49 @@ def test_package_remove_by_missing_id() -> None: assert actual == expected +def test_get_remote_package_sources_empty_without_remote_packages() -> None: + """A config without remote packages records no sources.""" + assert get_remote_package_sources() == () + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_get_remote_package_sources_records_each_fetch_once( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """Every fetched remote source is recorded exactly once.""" + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + mock_load_yaml.side_effect = lambda *args, **kwargs: OrderedDict() + + config = { + CONF_PACKAGES: { + "first": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: [TEST_YAML_FILENAME], + }, + "again": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: ["other.yaml"], + }, + "second": { + CONF_URL: "https://github.com/esphome/repo-b", + CONF_FILES: [TEST_YAML_FILENAME], + }, + } + } + packages_pass(config) + + # packages are processed last to first, so repo-b is fetched before repo-a + assert get_remote_package_sources() == ( + RemotePackageSource("https://github.com/esphome/repo-b", None), + RemotePackageSource("https://github.com/esphome/repo-a", "main"), + ) + + @patch("esphome.yaml_util.load_yaml") @patch("pathlib.Path.is_file") @patch("esphome.git.clone_or_update") diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py index e69de29bb2..da300b47b1 100644 --- a/tests/component_tests/remote_transmitter/__init__.py +++ b/tests/component_tests/remote_transmitter/__init__.py @@ -0,0 +1,13 @@ +"""Helpers for the remote_transmitter component tests.""" + +import re + +_PROTOCOL_CALL_BODY = re.compile( + r"auto apply_call = \S+->(?:protocol|encode)_call\(\);\n(.*?)apply_call\.perform\(\);", + re.DOTALL, +) + + +def protocol_call_bodies(main_cpp: str) -> list[str]: + """The statements of each generated transmit action, in config order.""" + return _PROTOCOL_CALL_BODY.findall(main_cpp) diff --git a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml new file mode 100644 index 0000000000..106ac129e2 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml @@ -0,0 +1,38 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: ABBWelcome fixed id + on_press: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x123456 + destination_address: 0x56789A + three_byte_address: true + message_type: 0x01 + message_id: 0x42 + data: [0x10] + - platform: template + name: AEHA lambda + on_press: + - remote_transmitter.transmit_aeha: + address: !lambda return 0x8008; + carrier_frequency: 36700Hz + data: !lambda return {0x00, 0x02}; + - platform: template + name: Midea lambda + on_press: + - remote_transmitter.transmit_midea: + code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Pioneer WYT lambda + on_press: + - remote_transmitter.transmit_pioneer_wyt: + code: !lambda return {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80}; diff --git a/tests/component_tests/remote_transmitter/config/ira211_action.yaml b/tests/component_tests/remote_transmitter/config/ira211_action.yaml new file mode 100644 index 0000000000..3cba9245ab --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/ira211_action.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: IRA211 constant + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: 22.5 + mode: timer + fan_mode: high + - platform: template + name: IRA211 lambda temperature + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: !lambda return 21.0f; diff --git a/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml b/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml new file mode 100644 index 0000000000..a91040ebab --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: RC6 one + on_press: + - remote_transmitter.transmit_rc6: + address: 0x00 + command: 0x0C + - platform: template + name: RC6 two + on_press: + - remote_transmitter.transmit_rc6: + address: 0x00 + command: 0x0D + - platform: template + name: Pronto + on_press: + - remote_transmitter.transmit_pronto: + data: "0000 006D 0001 0000 0015 0016" diff --git a/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml b/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml new file mode 100644 index 0000000000..5cd9227e6d --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml @@ -0,0 +1,41 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Toto default repeat + on_press: + - remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + - platform: template + name: Toto explicit repeat + on_press: + - remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + repeat: + times: 2 + wait_time: 20ms + - platform: template + name: NEC default wait + on_press: + - remote_transmitter.transmit_nec: + address: 0x4242 + command: 0x8484 + repeat: 4 + - platform: template + name: NEC once + on_press: + - remote_transmitter.transmit_nec: + address: 0x4242 + command: 0x8484 diff --git a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py index a653b5137e..ea1dbcf294 100644 --- a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -9,13 +9,17 @@ def test_constant_data_shares_progmem_tables( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """Equal data shares one table, empty or missing data is an empty table, lambdas stay templates.""" + """Equal data shares one table, empty data is a null table, lambdas are called inline.""" main_cpp = generate_main(component_config_path("abbwelcome_payload_tables.yaml")) tables = re.findall( r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp ) assert [v for _, v in tables] == ["{0x10, 0x20, 0x30}"] - assert main_cpp.count(f"set_data_static({tables[0][0]}, 3);") == 2 - assert main_cpp.count("set_data_static(nullptr, 0);") == 2 - assert main_cpp.count("set_data_template(") == 1 + assert main_cpp.count(f"apply_call.data.set_data({tables[0][0]}, 3);") == 2 + # Empty data is a null table; missing data leaves the default empty payload + assert main_cpp.count("apply_call.data.set_data(nullptr, 0);") == 1 + assert ( + main_cpp.count("apply_call.data.set_data([]() -> std::vector {") == 1 + ) + assert "std::vector{" not in main_cpp diff --git a/tests/component_tests/remote_transmitter/test_byte_payloads.py b/tests/component_tests/remote_transmitter/test_byte_payloads.py new file mode 100644 index 0000000000..0cdde16d4f --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_byte_payloads.py @@ -0,0 +1,39 @@ +"""The byte payload transmit actions as generated code.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_byte_payloads( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("byte_payloads.yaml")) + abb, aeha, midea, pioneer_wyt = protocol_call_bodies(main_cpp) + # The address length goes first, a configured id turns the automatic one off + assert abb.index("set_three_byte_address(true);") < abb.index("set_source_address(") + assert abb.index("auto_message_id = true;") < abb.index( + "set_fixed_message_id(0x42);" + ) + assert abb.rstrip().endswith("apply_call.data.finalize();") + # The carrier is set before the payload is encoded + assert aeha.index("set_carrier_frequency(36700);") < aeha.index( + "apply_call.encode(" + ) + assert ( + "apply_call.encode(static_cast(0x8008), []() -> std::vector {" + in aeha + ) + assert ( + "apply_call.data = remote_base::MideaData([]() -> std::vector {" + in midea + ) + assert midea.rstrip().endswith("apply_call.data.finalize();") + # A 13-byte code is copied into the frame and the checksum is appended by finalize() + assert ( + "apply_call.data = remote_base::PioneerWytData([]() -> std::vector {" + in pioneer_wyt + ) + assert pioneer_wyt.rstrip().endswith("apply_call.data.finalize();") diff --git a/tests/component_tests/remote_transmitter/test_ir_codes.py b/tests/component_tests/remote_transmitter/test_ir_codes.py index 9be34941b6..8d08e52ffd 100644 --- a/tests/component_tests/remote_transmitter/test_ir_codes.py +++ b/tests/component_tests/remote_transmitter/test_ir_codes.py @@ -1,4 +1,4 @@ -"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas.""" +"""Constant IR byte codes are encoded from shared PROGMEM tables; lambdas are called inline.""" from collections.abc import Callable from pathlib import Path @@ -16,9 +16,10 @@ def test_ir_codes_use_shared_progmem_tables( ) assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage midea = tables[0] - assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2 - assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp) - assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp) - assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp) - assert "->set_code_template([](" in main_cpp + assert main_cpp.count(f"apply_call.data = remote_base::MideaData({midea}, 5);") == 2 + assert re.search(r"apply_call\.encode\(0x8008, remote_base_code\w*, 6\);", main_cpp) + assert re.search(r"apply_call\.encode\(remote_base_code\w*, 13\);", main_cpp) + assert re.search(r"apply_call\.encode\(remote_base_code\w*, 14\);", main_cpp) + assert "apply_call.encode([]() -> std::vector {" in main_cpp + # Constant codes are read from the table, never copied into a vector assert "std::vector{" not in main_cpp diff --git a/tests/component_tests/remote_transmitter/test_ira211_action.py b/tests/component_tests/remote_transmitter/test_ira211_action.py new file mode 100644 index 0000000000..2d202998f1 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_ira211_action.py @@ -0,0 +1,20 @@ +"""transmit_ira211 sets the temperature through IRA211Data::set_temperature().""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_ira211_temperature_is_a_statement_on_the_data( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("ira211_action.yaml")) + constant, lambda_ = protocol_call_bodies(main_cpp) + assert "apply_call.data.set_temperature(22.5f);" in constant + assert ( + "apply_call.data.mode = remote_base::IRA211Mode::IRA211_MODE_TIMER;" in constant + ) + assert "apply_call.data.fan = remote_base::IRA211Fan::IRA211_FAN_HIGH;" in constant + assert "apply_call.data.set_temperature(static_cast(21.0f));" in lambda_ diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index aafab1f2c8..cbc1efb0a6 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -1,18 +1,23 @@ -"""transmit_keeloq sets the suffix only when suffix bits are sent.""" +"""transmit_keeloq sets the suffix only when the config names it.""" from collections.abc import Callable from pathlib import Path +from tests.component_tests.remote_transmitter import protocol_call_bodies -def test_keeloq_suffix_is_set_only_with_suffix_bits( + +def test_keeloq_suffix_is_set_only_when_configured( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) - - # The plain word gets the same setters as before the suffix existed. - assert "remote_base_keeloqaction_id->set_suffix" not in main_cpp - # A constant and a lambda width both set the suffix. - for action in ("remote_base_keeloqaction_id_2", "remote_base_keeloqaction_id_3"): - assert f"{action}->set_suffix([]() -> uint16_t" in main_cpp - assert f"{action}->set_suffix_bits([]() -> uint8_t" in main_cpp + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 3 + plain, constant, lambda_ = bodies + # The plain word fills only the HCS301 fields. + assert "data.suffix" not in plain + assert "apply_call.data.suffix = 0x2A;" in constant + assert "apply_call.data.suffix_bits = 6;" in constant + # A lambda is called inline. + assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ + assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ diff --git a/tests/component_tests/remote_transmitter/test_rc6_pronto.py b/tests/component_tests/remote_transmitter/test_rc6_pronto.py new file mode 100644 index 0000000000..ba68f5a48b --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_rc6_pronto.py @@ -0,0 +1,41 @@ +"""transmit_rc6 keeps a toggle bit per action, and transmit_pronto sends its string.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome import automation +import esphome.codegen as cg +from esphome.components.remote_base import _protocol_field +from esphome.const import CONF_TEMPERATURE +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_rc6_toggle_and_pronto( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rc6_pronto.yaml")) + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 3 + toggle = ( + "apply_call.data.toggle = []() { static uint8_t toggle; return toggle++; }();" + ) + # Each RC6 action has its own generated function, so its own toggle static. + assert toggle in bodies[0] + assert toggle in bodies[1] + assert ( + 'apply_call.data.data = progmem_string(ESPHOME_F("0000 006D 0001 0000 0015 0016"));' + in bodies[2] + ) + assert "delta" not in bodies[2] + + +def test_apply_objects_pass_through() -> None: + call = automation.ApplyCall("stop()") + assert _protocol_field(call) is call + field = automation.ApplyField( + CONF_TEMPERATURE, "data.set_temperature({})", cg.float_ + ) + assert _protocol_field(field) is field + plain = _protocol_field((CONF_TEMPERATURE, "temperature", cg.float_)) + assert plain.target == "data.temperature = {}" diff --git a/tests/component_tests/remote_transmitter/test_transmit_repeat.py b/tests/component_tests/remote_transmitter/test_transmit_repeat.py new file mode 100644 index 0000000000..691c9e6b4e --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_transmit_repeat.py @@ -0,0 +1,27 @@ +"""repeat on a generated transmit action, and toto's default.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_transmit_repeat( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("transmit_repeat.yaml")) + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 4 + toto_default, toto_explicit, nec_repeat, nec_once = bodies + # toto sends 3 times 36 ms apart unless the config says otherwise. + assert "apply_call.set_send_times(3);" in toto_default + assert "apply_call.set_send_wait(36000);" in toto_default + assert "apply_call.set_send_times(2);" in toto_explicit + assert "apply_call.set_send_wait(20000);" in toto_explicit + # A bare count gets the 25 ms default wait, in microseconds. + assert "apply_call.set_send_times(4);" in nec_repeat + assert "apply_call.set_send_wait(25000);" in nec_repeat + # Without repeat neither setter is emitted; the call sends once. + assert "set_send_times" not in nec_once + assert "set_send_wait" not in nec_once diff --git a/tests/component_tests/sensor/test_calibrate.yaml b/tests/component_tests/sensor/test_calibrate.yaml new file mode 100644 index 0000000000..41a87c8779 --- /dev/null +++ b/tests/component_tests/sensor/test_calibrate.yaml @@ -0,0 +1,51 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +sensor: + - platform: template + id: exact_a + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: exact_b + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: least_squares + lambda: return 1.0; + filters: + - calibrate_linear: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + - platform: template + id: polynomial + lambda: return 1.0; + filters: + - calibrate_polynomial: + degree: 1 + datapoints: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + # User ids equal to the old derived table name and to the helper's base name. + - platform: template + id: sensor_calibratelinearfilter_id_data + lambda: return 1.0; + - platform: template + id: sensor_calibrate_linear + lambda: return 1.0; diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 9d18fa36b8..07981c1897 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,5 +1,8 @@ """Tests for the sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import extract_packed_value @@ -15,3 +18,27 @@ def test_sensor_device_class_set(generate_main): # Then: device_class: voltage means packed value must be non-zero packed = extract_packed_value(main_cpp, "s_1") assert packed != 0 + + +def test_calibration_tables_are_shared_progmem( + generate_main: Callable[[str], str], +) -> None: + """Calibration data is a PROGMEM table, shared by filters with identical data.""" + main_cpp = generate_main("tests/component_tests/sensor/test_calibrate.yaml") + + linear = re.findall( + r"static constexpr std::array (\w+)\[\] PROGMEM", main_cpp + ) + assert len(linear) == 2 # the two exact filters share one table + exact, least_squares = linear + assert main_cpp.count(f"sensor::CalibrateLinearFilter({exact});") == 2 + assert f"sensor::CalibrateLinearFilter({least_squares});" in main_cpp + + poly = re.search( + r"static constexpr float (\w+)\[\] PROGMEM = \{2\.0f, 1\.0f, 2\.0f\};", main_cpp + ) + assert poly is not None + assert f"sensor::CalibratePolynomialFilter({poly.group(1)});" in main_cpp + # Table names never reuse a user id. + assert exact != "sensor_calibrate_linear" + assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares) diff --git a/tests/component_tests/sensor/test_value_list.py b/tests/component_tests/sensor/test_value_list.py new file mode 100644 index 0000000000..168fee2427 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.py @@ -0,0 +1,43 @@ +"""Tests for the sensor value list filter codegen.""" + +from collections.abc import Callable +import re + + +def test_value_list_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Identical value lists share one PROGMEM table; the NaN throttle stays array free.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM", main_cpp + ) + # Every table ends with an empty entry so the filters store no count. + assert main_cpp.count("TemplatableFn()}") == len(tables) + assert ( + len(tables) == 5 + ) # single, shared list, lambda, two anchored stateful lambdas + single, shared, lam = tables[:3] + assert f"sensor::FilterOutValueFilter({single});" in main_cpp + assert main_cpp.count(f"sensor::FilterOutValueFilter({shared});") == 2 + assert f"sensor::FilterOutValueFilter({lam});" in main_cpp + assert f"sensor::ThrottleWithPriorityFilter(1000, {shared});" in main_cpp + assert "sensor::ThrottleWithPriorityNanFilter(1000);" in main_cpp + assert "return 42.0;" in main_cpp + + +def test_value_list_lambda_tables_are_not_shared( + generate_main: Callable[[str], str], +) -> None: + """An anchored lambda may keep static state, so each filter keeps its own table.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM = (.*?)TemplatableFn\(\)\}", + main_cpp, + re.DOTALL, + ) + stateful = [name for name, body in tables if "static float n" in body] + assert len(stateful) == 2 + assert stateful[0] != stateful[1] diff --git a/tests/component_tests/sensor/test_value_list.yaml b/tests/component_tests/sensor/test_value_list.yaml new file mode 100644 index 0000000000..60a044f483 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.yaml @@ -0,0 +1,51 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: fo_single + lambda: return 1.0; + filters: + - filter_out: 85.0 + - platform: template + id: fo_list_a + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_list_b + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_lambda + lambda: return 1.0; + filters: + - filter_out: !lambda return 42.0; + - platform: template + id: twp + lambda: return 1.0; + filters: + - throttle_with_priority: + timeout: 1s + value: [0.0, 85.0] + - platform: template + id: twp_nan + lambda: return 1.0; + filters: + - throttle_with_priority: 1s + - platform: template + id: fo_stateful_a + lambda: return 1.0; + filters: + - filter_out: &stateful !lambda |- + static float n = 0; + return ++n; + - platform: template + id: fo_stateful_b + lambda: return 1.0; + filters: + - filter_out: *stateful diff --git a/tests/component_tests/tca8418/__init__.py b/tests/component_tests/tca8418/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/tca8418/test_final_validate.py b/tests/component_tests/tca8418/test_final_validate.py new file mode 100644 index 0000000000..48569f3f9e --- /dev/null +++ b/tests/component_tests/tca8418/test_final_validate.py @@ -0,0 +1,156 @@ +"""Tests for how the tca8418 binary sensor resolves its key against the keypad.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import CONF_GPI_EVENTS +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_COL, + CONF_ID, + CONF_KEY, + CONF_NAME, + CONF_ROW, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "keypad" + + +def _full_config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = "123456789*0#", + gpi_events: bool = True, +) -> Config: + """A full config carrying one keypad, as the ID pass leaves it.""" + hub: ConfigType = { + CONF_ID: ID(HUB_ID, is_declaration=True, type="tca8418"), + CONF_ROWS: rows, + CONF_COLUMNS: columns, + CONF_GPI_EVENTS: gpi_events, + } + if keys is not None: + hub[CONF_KEYS] = keys + full = Config() + full["tca8418"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["tca8418", 0, CONF_ID])) + return full + + +def _sensor(**selector: object) -> ConfigType: + config: ConfigType = { + "keypad_id": ID(HUB_ID, is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + } + config.update(selector) + return config + + +def _validated(config: ConfigType) -> ConfigType: + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +@pytest.fixture +def keypad(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + + +# A key number is worked out here so the sensor only compares numbers at run +# time. Matrix keys are row * 10 + column + 1. +@pytest.mark.parametrize( + ("selector", "key_code"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, 1), + ({CONF_ROW: 3, CONF_COL: 2}, 33), + ({CONF_KEY: "1"}, 1), + ({CONF_KEY: "5"}, 12), + ({CONF_KEY: "#"}, 33), + ({CONF_KEY_CODE: 12}, 12), + ], +) +def test_key_is_resolved(keypad: None, selector: ConfigType, key_code: int) -> None: + assert _validated(_sensor(**selector))[CONF_KEY_CODE] == key_code + + +def test_position_outside_the_matrix_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="outside the 4 x 3 key matrix"): + _validated(_sensor(row=0, col=3)) + + +def test_key_number_outside_the_matrix_is_rejected(keypad: None) -> None: + """Key 5 is ROW0/COL4, which a three column matrix never reports.""" + with pytest.raises(cv.Invalid, match="position 0/4, which is outside"): + _validated(_sensor(key_code=5)) + + +def test_key_number_of_a_pin_the_matrix_uses_is_rejected(keypad: None) -> None: + """97 is ROW0, which belongs to the matrix, so it cannot report on its own.""" + with pytest.raises(cv.Invalid, match="ROW0, which the key matrix is using"): + _validated(_sensor(key_code=97)) + + +def test_individual_input_outside_the_matrix_passes(keypad: None) -> None: + """ROW5 is past the four rows of the matrix, so it reports on its own.""" + assert _validated(_sensor(key_code=102))[CONF_KEY_CODE] == 102 + + +def test_column_beyond_the_matrix_passes(keypad: None) -> None: + """COL3 is past the three columns of the matrix; 105 is COL0.""" + assert _validated(_sensor(key_code=108))[CONF_KEY_CODE] == 108 + + +def test_individual_input_needs_gpi_events( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(gpi_events=False) + ) + + with pytest.raises(cv.Invalid, match="'gpi_events' turned off"): + _validated(_sensor(key_code=102)) + + +def test_character_without_a_key_map_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config(keys=None)) + + with pytest.raises(cv.Invalid, match="which is not set"): + _validated(_sensor(key="5")) + + +def test_character_not_in_the_key_map_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="not one of the keypad's keys"): + _validated(_sensor(key="Z")) + + +def test_repeated_character_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A character appearing twice does not name one key.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(keys="1234567891*0") + ) + + with pytest.raises(cv.Invalid, match="appears more than once"): + _validated(_sensor(key="1")) + + +def test_key_given_as_a_number_is_accepted(keypad: None) -> None: + """'key: 5' is a reasonable way to name the key labelled 5.""" + assert _validated(_sensor(key=5))[CONF_KEY_CODE] == 12 diff --git a/tests/component_tests/tca8418/test_validate.py b/tests/component_tests/tca8418/test_validate.py new file mode 100644 index 0000000000..fbadd42363 --- /dev/null +++ b/tests/component_tests/tca8418/test_validate.py @@ -0,0 +1,142 @@ +"""Tests for the tca8418 keypad and binary sensor configuration schemas.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import ( + CONF_GPI_EVENTS, + CONFIG_SCHEMA as HUB_SCHEMA, + _validate, +) +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA as SENSOR_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_NAME, CONF_ROW +from esphome.core import ID +from esphome.types import ConfigType + + +def _config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = None, + gpi_events: bool = True, +) -> ConfigType: + config: ConfigType = {CONF_GPI_EVENTS: gpi_events} + if rows: + config[CONF_ROWS] = rows + if columns: + config[CONF_COLUMNS] = columns + if keys is not None: + config[CONF_KEYS] = keys + return config + + +def test_a_matrix_with_a_key_map_passes() -> None: + _validate(_config(keys="123456789*0#")) + + +def test_individual_inputs_only_passes() -> None: + """No matrix at all is fine as long as the pins report on their own.""" + _validate(_config(rows=0, columns=0)) + + +@pytest.mark.parametrize("half", [{CONF_ROWS: 4}, {CONF_COLUMNS: 3}]) +def test_half_a_matrix_is_rejected(half: ConfigType) -> None: + config = {CONF_ID: ID("k", is_declaration=True), "i2c_id": ID("bus"), **half} + with pytest.raises(cv.Invalid, match="must both be set"): + HUB_SCHEMA(config) + + +def test_key_map_without_a_matrix_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="'rows' and 'columns' are required"): + _validate(_config(rows=0, columns=0, keys="123")) + + +@pytest.mark.parametrize("keys", ["12345678901", "12345678"]) +def test_key_map_of_the_wrong_length_is_rejected(keys: str) -> None: + """A key map names every key of the matrix, so its length is fixed.""" + with pytest.raises(cv.Invalid, match="must have exactly 12 characters"): + _validate(_config(keys=keys)) + + +def test_a_keypad_that_reports_nothing_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="Nothing to report"): + _validate(_config(rows=0, columns=0, gpi_events=False)) + + +def test_letters_that_clash_with_individual_inputs_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + """97 to 114 are individual inputs and also the codes for 'a' to 'r'.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#")) + + assert "cannot be told apart" in caplog.text + assert "'a', 'b', 'c'" in caplog.text + + +def test_other_characters_do_not_warn(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + _validate(_config(keys="123456789*0#")) + + assert caplog.text == "" + + +def test_no_warning_without_individual_inputs( + caplog: pytest.LogCaptureFixture, +) -> None: + """With the individual inputs off there is nothing for a letter to clash with.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#", gpi_events=False)) + + assert caplog.text == "" + + +def _sensor(**selector: object) -> ConfigType: + return { + "keypad_id": ID("keypad", is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + **selector, + } + + +# Checks made before the keypad is consulted: a key is named exactly one way, +# a position has both halves, a character is one ASCII character, and a key +# number is one the device uses (81 to 96 are unused, numbering stops at 114). +@pytest.mark.parametrize( + ("selector", "error"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, None), + ({CONF_KEY: "5"}, None), + ({CONF_KEY: 5}, None), + ({CONF_KEY_CODE: 12}, None), + ({CONF_KEY_CODE: 102}, None), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY: "5"}, "more than one of"), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY_CODE: 12}, "more than one of"), + ({CONF_KEY: "5", CONF_KEY_CODE: 12}, "more than one of"), + ({}, "exactly one of"), + ({CONF_ROW: 0}, "must be given together"), + ({CONF_COL: 0}, "must be given together"), + ({CONF_KEY: "ab"}, "single character"), + ({CONF_KEY: ""}, "single character"), + ({CONF_KEY: "\u00e9"}, "Only ASCII characters"), + ({CONF_KEY_CODE: 0}, "Key numbers run from"), + ({CONF_KEY_CODE: 81}, "Key numbers run from"), + ({CONF_KEY_CODE: 96}, "Key numbers run from"), + ({CONF_KEY_CODE: 115}, "Key numbers run from"), + ], +) +def test_sensor_schema(selector: ConfigType, error: str | None) -> None: + if error is None: + SENSOR_SCHEMA(_sensor(**selector)) + return + with pytest.raises(cv.Invalid, match=error): + SENSOR_SCHEMA(_sensor(**selector)) diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index eb25af3095..9eda24f7b9 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -1,5 +1,8 @@ """Tests for the text sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value @@ -62,3 +65,38 @@ def test_text_sensor_device_class_set(generate_main): assert packed_ts_2 != 0 packed_ts_3 = extract_packed_value(main_cpp, "ts_3") assert packed_ts_3 != 0 + + +def test_substitution_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Map and substitute pairs live in PROGMEM tables; equal lists share one.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + map_table, sub_table = tables + assert main_cpp.count(f"text_sensor::MapFilter({map_table})") == 2 + assert f"text_sensor::SubstituteFilter({sub_table})" in main_cpp + # Each table ends with an empty pair so the filters store no count. + assert main_cpp.count(".from = nullptr") == 2 + + +def test_substitution_table_names_avoid_user_ids( + generate_main: Callable[[str], str], +) -> None: + """A user id equal to a table name cannot collide with the generated table.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + assert "text_sensor_substitutions" not in tables + assert "text_sensor_mapfilter_id_table" not in tables diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml new file mode 100644 index 0000000000..d0526cab19 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: text_sensor_substitutions + filters: + - map: + - "ON -> Open" + - platform: template + id: text_sensor_mapfilter_id_table + filters: + - map: + - "OFF -> Closed" diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml new file mode 100644 index 0000000000..ac422c3309 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: ts_map_a + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_map_b + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_sub + filters: + - substitute: + - "foo -> bar" diff --git a/tests/component_tests/udp/test_udp_listen_address.py b/tests/component_tests/udp/test_udp_listen_address.py new file mode 100644 index 0000000000..8c9718ca36 --- /dev/null +++ b/tests/component_tests/udp/test_udp_listen_address.py @@ -0,0 +1,135 @@ +"""Tests for the udp address validators.""" + +from ipaddress import ip_address + +import pytest + +from esphome.components.udp import ( + CONF_ADDRESSES, + CONF_LISTEN_ADDRESS, + _address, + _final_validate, + _listen_address, +) +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_IPV6, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("value", ["239.0.60.53", "255.255.255.255", "ff12::cafe"]) +def test_listen_address_accepts_multicast_and_broadcast(value: str) -> None: + assert str(_listen_address(value)) == value + + +@pytest.mark.parametrize( + ("value", "message"), + [ + # An IPv4 address must report the IPv4 error, not an IPv6 parse failure + ("192.168.1.10", "not a multicast address nor local broadcast address"), + ("fe80::1", "not an IPv6 multicast address"), + ("ff02::cafe%eth0", "carries a scope id"), + ("gg::1", "not a valid IPv6 address"), + ], +) +def test_listen_address_reports_matching_family_error(value: str, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + _listen_address(value) + + +@pytest.mark.parametrize( + "value", ["10.0.0.1", "255.255.255.255", "ff12::cafe", "2001:db8::1"] +) +def test_address_accepts_routable_destinations(value: str) -> None: + assert str(_address(value)) == value + + +@pytest.mark.parametrize( + ("value", "message"), + [ + # The device parses addresses with inet_pton, which takes no scope suffix + ("2001:db8::1%wlan0", "carries a scope id"), + # No scope id is set on the destination, so a link-local send would fail + ("fe80::1", "is link-local"), + ], +) +def test_address_rejects_unsendable_destinations(value: str, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + _address(value) + + +_RAW_LWIP_IPV6 = { + "network": {CONF_ENABLE_IPV6: True}, + "socket": {"implementation": "lwip_tcp"}, +} +_NO_IPV6 = "requires 'enable_ipv6: true'" +_RAW_LWIP = "not supported on this platform" + + +@pytest.mark.parametrize( + ("addresses", "listen_address", "platform", "full_config", "message"), + [ + pytest.param( + ["ff12::cafe"], + "255.255.255.255", + PlatformFramework.ESP32_IDF, + {}, + _NO_IPV6, + id="ipv6_destination_without_ipv6", + ), + pytest.param( + ["255.255.255.255"], + "ff12::cafe", + PlatformFramework.ESP32_IDF, + {}, + _NO_IPV6, + id="ipv6_listen_without_ipv6", + ), + pytest.param( + ["ff12::cafe"], + "255.255.255.255", + PlatformFramework.ESP8266_ARDUINO, + _RAW_LWIP_IPV6, + _RAW_LWIP, + id="ipv6_destination_on_raw_lwip", + ), + pytest.param( + ["255.255.255.255"], + "ff12::cafe", + PlatformFramework.ESP8266_ARDUINO, + _RAW_LWIP_IPV6, + _RAW_LWIP, + id="ipv6_listen_on_raw_lwip", + ), + ], +) +def test_final_validate_rejects_unsupported_ipv6( + set_core_config: SetCoreConfigCallable, + addresses: list[str], + listen_address: str, + platform: PlatformFramework, + full_config: dict, + message: str, +) -> None: + set_core_config(platform, full_config=full_config) + with pytest.raises(cv.Invalid, match=message): + _final_validate( + { + CONF_ADDRESSES: [ip_address(a) for a in addresses], + CONF_LISTEN_ADDRESS: ip_address(listen_address), + } + ) + + +def test_final_validate_accepts_ipv6_when_enabled( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"network": {CONF_ENABLE_IPV6: True}}, + ) + _final_validate( + { + CONF_ADDRESSES: [ip_address("ff12::cafe")], + CONF_LISTEN_ADDRESS: ip_address("ff12::cafe"), + } + ) diff --git a/tests/component_tests/whirlpool/__init__.py b/tests/component_tests/whirlpool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/whirlpool/test_model_define.py b/tests/component_tests/whirlpool/test_model_define.py new file mode 100644 index 0000000000..1f6840f300 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.py @@ -0,0 +1,27 @@ +"""The Hisense ARG code is compiled in only when that model is configured.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DEFINE = "USE_WHIRLPOOL_HISENSE_ARG" +HERE = Path(__file__).parent + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_hisense_arg_model_emits_the_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(HERE / "test_model_define.yaml") + assert DEFINE in _define_names() + + +def test_dg11j1_model_does_not_emit_the_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(HERE / "test_model_define_dg11j1.yaml") + assert DEFINE not in _define_names() diff --git a/tests/component_tests/whirlpool/test_model_define.yaml b/tests/component_tests/whirlpool/test_model_define.yaml new file mode 100644 index 0000000000..80c0bc142b --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + id: xmitr + pin: GPIO4 + carrier_duty_percent: 50% + +climate: + - platform: whirlpool + name: Hisense + model: HISENSE_ARG + transmitter_id: xmitr diff --git a/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml new file mode 100644 index 0000000000..37a8036265 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + id: xmitr + pin: GPIO4 + carrier_duty_percent: 50% + +climate: + - platform: whirlpool + name: Whirlpool + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/component_tests/wifi/test_lease_cache.py b/tests/component_tests/wifi/test_lease_cache.py new file mode 100644 index 0000000000..56a9f1b66b --- /dev/null +++ b/tests/component_tests/wifi/test_lease_cache.py @@ -0,0 +1,41 @@ +"""Tests for the lease_cache storage validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32C2 +from esphome.components.wifi import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(lease_cache: dict) -> dict: + return CONFIG_SCHEMA( + { + "ssid": "net", + "password": "secret12", + "use_address": "test.local", + "lease_cache": lease_cache, + } + ) + + +def test_storage_defaults_to_rtc(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + config = _validate({"max_age": "12h"}) + assert config["lease_cache"]["storage"] == "rtc" + + +def test_no_rtc_variant_requires_flash(set_core_config: SetCoreConfigCallable) -> None: + """A variant without RTC memory must say storage: flash; the error points at that key.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C2} + ) + with pytest.raises(cv.Invalid, match="no RTC memory") as exc: + _validate({"max_age": "12h"}) + assert exc.value.path == ["lease_cache", "storage"] + config = _validate({"storage": "flash"}) + assert config["lease_cache"]["storage"] == "flash" diff --git a/tests/component_tests/wifi/test_min_auth_mode.py b/tests/component_tests/wifi/test_min_auth_mode.py new file mode 100644 index 0000000000..e0e5ca9805 --- /dev/null +++ b/tests/component_tests/wifi/test_min_auth_mode.py @@ -0,0 +1,79 @@ +"""Platform restrictions for minimum Wi-Fi authentication modes.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32 +from esphome.components.wifi import CONF_MIN_AUTH_MODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(mode: str | None = None) -> dict: + config = {"ssid": "net", "password": "secret12", "use_address": "test.local"} + if mode is not None: + config[CONF_MIN_AUTH_MODE] = mode + return CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("mode", ["WPA3", "wpa3"]) +def test_esp8266_rejects_wpa3( + set_core_config: SetCoreConfigCallable, mode: str +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP8266 does not support WPA3") as exc: + _validate(mode) + assert exc.value.path == [CONF_MIN_AUTH_MODE] + + +@pytest.mark.parametrize( + ("platform", "mode"), + [ + (PlatformFramework.ESP8266_ARDUINO, "WPA"), + (PlatformFramework.ESP8266_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA"), + (PlatformFramework.ESP32_IDF, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA3"), + (PlatformFramework.ESP32_IDF, "wpa3"), + (PlatformFramework.ESP32_ARDUINO, "WPA"), + (PlatformFramework.ESP32_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_ARDUINO, "WPA3"), + ], +) +def test_supported_modes( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework, mode: str +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate(mode)[CONF_MIN_AUTH_MODE] == mode.upper() + + +@pytest.mark.parametrize( + "platform", + [ + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + ], +) +def test_default_remains_wpa2( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate()[CONF_MIN_AUTH_MODE] == "WPA2" diff --git a/tests/components/apc1/common.yaml b/tests/components/apc1/common.yaml new file mode 100644 index 0000000000..496f587e42 --- /dev/null +++ b/tests/components/apc1/common.yaml @@ -0,0 +1,62 @@ +sensor: + - platform: apc1 + id: apc1_sensor + set_pin: ${set_pin} + reset_pin: ${reset_pin} + pm_1_0: + name: "APC1 PM1.0" + pm_2_5: + name: "APC1 PM2.5" + filters: + - throttle_average: 10s + pm_10_0: + name: "APC1 PM10" + pm_1_0_std: + name: "APC1 PM1.0 Std" + pm_2_5_std: + name: "APC1 PM2.5 Std" + pm_10_0_std: + name: "APC1 PM10 Std" + pm_0_3um: + name: "APC1 Particles >0.3um" + pm_0_5um: + name: "APC1 Particles >0.5um" + pm_1_0um: + name: "APC1 Particles >1.0um" + pm_2_5um: + name: "APC1 Particles >2.5um" + pm_5_0um: + name: "APC1 Particles >5.0um" + pm_10_0um: + name: "APC1 Particles >10um" + tvoc: + name: "APC1 TVOC" + eco2: + name: "APC1 eCO2" + aqi: + name: "APC1 AQI" + temperature: + name: "APC1 Temperature" + humidity: + name: "APC1 Humidity" + raw_temperature: + name: "APC1 Raw Temperature" + raw_humidity: + name: "APC1 Raw Humidity" + rs0: + name: "APC1 RS0" + rs2: + name: "APC1 MOX RS2" + rs3: + name: "APC1 MOX RS3" + error_code: + name: "APC1 Error Code" + +interval: + - interval: 60s + then: + - apc1.set_active_mode: apc1_sensor + - apc1.set_passive_mode: apc1_sensor + - apc1.request_measurement: apc1_sensor + - apc1.set_idle_mode: apc1_sensor + - apc1.set_measurement_mode: apc1_sensor diff --git a/tests/components/apc1/test.esp32-idf.yaml b/tests/components/apc1/test.esp32-idf.yaml new file mode 100644 index 0000000000..a8fdc5956d --- /dev/null +++ b/tests/components/apc1/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO32 + reset_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.esp8266-ard.yaml b/tests/components/apc1/test.esp8266-ard.yaml new file mode 100644 index 0000000000..746829bea7 --- /dev/null +++ b/tests/components/apc1/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO4 + reset_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.rp2040-ard.yaml b/tests/components/apc1/test.rp2040-ard.yaml new file mode 100644 index 0000000000..e1dc80743f --- /dev/null +++ b/tests/components/apc1/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO2 + reset_pin: GPIO3 + +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + apc1: !include common.yaml diff --git a/tests/components/apds9930/common.yaml b/tests/components/apds9930/common.yaml new file mode 100644 index 0000000000..2cb9547e5c --- /dev/null +++ b/tests/components/apds9930/common.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: apds9930 + i2c_id: i2c_bus + id: apds9930_sensor + address: 0x39 + update_interval: 30s + led_drive: 25ma + proximity_gain: 4x + ambient_light_gain: 16x + illuminance: + name: APDS9930 Illuminance + proximity: + name: APDS9930 Proximity diff --git a/tests/components/apds9930/test.esp32-idf.yaml b/tests/components/apds9930/test.esp32-idf.yaml new file mode 100644 index 0000000000..8226f36aa7 --- /dev/null +++ b/tests/components/apds9930/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.esp8266-ard.yaml b/tests/components/apds9930/test.esp8266-ard.yaml new file mode 100644 index 0000000000..913a4adeea --- /dev/null +++ b/tests/components/apds9930/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.rp2040-ard.yaml b/tests/components/apds9930/test.rp2040-ard.yaml new file mode 100644 index 0000000000..be77d931df --- /dev/null +++ b/tests/components/apds9930/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + apds9930: !include common.yaml diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 1513d537c2..7f6cb83f16 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -181,7 +181,7 @@ api: room: !lambda 'return room;' ratio: !lambda 'return level / 100.0f;' - homeassistant.tag_scanned: !lambda 'return room;' - # Test ContinuationAction (IfAction with then/else branches) + # Test IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -199,7 +199,7 @@ api: format: "Condition false, value: %ld" args: ['(long) value'] - logger.log: "After if/else" - # Test nested IfAction (multiple ContinuationAction instances) + # Test nested IfAction - action: test_nested_if variables: outer: bool @@ -219,7 +219,7 @@ api: else: - logger.log: "Outer false" - logger.log: "After nested if" - # Test WhileLoopContinuation (WhileAction) + # Test WhileAction - action: test_while_action variables: max_count: int @@ -234,7 +234,7 @@ api: args: ['id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)++;' - logger.log: "After while loop" - # Test RepeatLoopContinuation (RepeatAction) + # Test RepeatAction - action: test_repeat_action variables: count: int diff --git a/tests/components/aranet/common-ln.yaml b/tests/components/aranet/common-ln.yaml new file mode 100644 index 0000000000..1652ae5c6a --- /dev/null +++ b/tests/components/aranet/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature diff --git a/tests/components/aranet/common.h b/tests/components/aranet/common.h new file mode 100644 index 0000000000..41b5001a1f --- /dev/null +++ b/tests/components/aranet/common.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "esphome/components/aranet/aranet.h" + +namespace esphome::aranet::testing { + +constexpr uint8_t MAC_ADDRESS[6] = {0x55, 0x44, 0x33, 0x22, 0x11, 0x00}; +constexpr uint64_t ADDRESS = 0x001122334455ULL; + +inline const std::vector ARANET4_PAYLOAD = { + 0x20, // Smart Home integrations enabled + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x02, // CO2: 600 ppm + 0xA4, 0x01, // Temperature: 21.0 C (420 / 20) + 0x94, 0x27, // Pressure: 1013.2 hPa (10132 / 10) + 45, // Humidity: 45% + 87, // Battery: 87% + 0x00, // Display status + 0x2C, 0x01, // Measurement interval: 300 s + 12, 0x00, // Measurement age: 12 s + 7, // Measurement counter +}; + +inline const std::vector ARANET2_PAYLOAD = {0x01, 0x21, 0x04, 0x04, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x99, 0x01, 0x00, 0x00, 0x0A, 0x02, + 0x00, 0x3B, 0x09, 0x78, 0x00, 0x52, 0x00, 0x64}; + +inline const std::vector RADIATION_PAYLOAD = {0x02, 0x21, 0x26, 0x04, 0x01, 0x00, 0xD0, 0x33, + 0x00, 0x00, 0x6C, 0x60, 0x06, 0x00, 0x82, 0x00, + 0x00, 0x63, 0x00, 0x2C, 0x01, 0x58, 0x00, 0x72}; + +inline const std::vector RADON_PAYLOAD = {0x03, 0x21, 0x04, 0x06, 0x01, 0x00, 0x00, 0x00, + 0x07, 0x00, 0xFE, 0x01, 0xC9, 0x27, 0xCE, 0x01, + 0x00, 0x64, 0x01, 0x58, 0x02, 0xF6, 0x01, 0x08}; + +inline ble_device_base::ESPBTDevice make_device(const std::vector &payload, int rssi = -61, + const uint8_t *mac_address = MAC_ADDRESS) { + // Manufacturer specific data AD structure for company id 0x0702 + std::vector advertisement{static_cast(payload.size() + 3), 0xFF, 0x02, 0x07}; + advertisement.insert(advertisement.end(), payload.begin(), payload.end()); + + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac_address, rssi, 0, advertisement.data(), advertisement.size()); + return device; +} + +struct Harness { + Harness() { + this->aranet.set_co2_sensor(&this->co2); + this->aranet.set_temperature_sensor(&this->temperature); + this->aranet.set_humidity_sensor(&this->humidity); + this->aranet.set_pressure_sensor(&this->pressure); + this->aranet.set_battery_level_sensor(&this->battery); + this->aranet.set_measurement_interval_sensor(&this->interval); + this->aranet.set_measurement_age_sensor(&this->age); + this->aranet.set_signal_strength_sensor(&this->signal_strength); + this->aranet.set_radon_sensor(&this->radon); + this->aranet.set_radiation_rate_sensor(&this->radiation_rate); + this->aranet.set_radiation_total_sensor(&this->radiation_total); + this->aranet.set_radiation_duration_sensor(&this->radiation_duration); + } + + Aranet aranet{ADDRESS}; + sensor::Sensor co2, temperature, humidity, pressure, battery, interval, age, signal_strength, radon, radiation_rate, + radiation_total, radiation_duration; +}; + +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/common.yaml b/tests/components/aranet/common.yaml new file mode 100644 index 0000000000..09338269ea --- /dev/null +++ b/tests/components/aranet/common.yaml @@ -0,0 +1,27 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature + humidity: + name: Aranet4 Humidity + pressure: + name: Aranet4 Pressure + battery_level: + name: Aranet4 Battery Level + measurement_interval: + name: Aranet4 Measurement Interval + measurement_age: + name: Aranet4 Measurement Age + signal_strength: + name: Aranet4 Signal Strength + radon: + name: Aranet Radon Concentration + radiation_rate: + name: Aranet Radiation Dose Rate + radiation_total: + name: Aranet Radiation Total Dose + radiation_duration: + name: Aranet Radiation Dose Duration diff --git a/tests/components/aranet/sensor/test_aranet.cpp b/tests/components/aranet/sensor/test_aranet.cpp new file mode 100644 index 0000000000..deba1114e5 --- /dev/null +++ b/tests/components/aranet/sensor/test_aranet.cpp @@ -0,0 +1,144 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::aranet::testing { +namespace { + +TEST(Aranet, DecodesAranet4) { + Harness h; + auto device = make_device(ARANET4_PAYLOAD); + ASSERT_EQ(device.address_uint64(), ADDRESS); + + EXPECT_TRUE(h.aranet.parse_device(device)); + EXPECT_FLOAT_EQ(h.co2.state, 600.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 21.0f); + EXPECT_FLOAT_EQ(h.humidity.state, 45.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1013.2f); + EXPECT_FLOAT_EQ(h.battery.state, 87.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 12.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -61.0f); + EXPECT_FALSE(h.radon.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, UpdatesDiagnosticsForRepeatedMeasurement) { + Harness h; + int co2_updates = 0; + int age_updates = 0; + int signal_strength_updates = 0; + h.co2.add_on_state_callback([&co2_updates](float) { co2_updates++; }); + h.age.add_on_state_callback([&age_updates](float) { age_updates++; }); + h.signal_strength.add_on_state_callback([&signal_strength_updates](float) { signal_strength_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61))); + auto payload = ARANET4_PAYLOAD; + payload[19] = 18; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload, -54))); + + EXPECT_EQ(co2_updates, 1); + EXPECT_EQ(age_updates, 2); + EXPECT_EQ(signal_strength_updates, 2); + EXPECT_FLOAT_EQ(h.age.state, 18.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -54.0f); +} + +TEST(Aranet, RejectsDisabledIntegrationAndMalformedPayload) { + Harness h; + auto payload = ARANET4_PAYLOAD; + + payload[0] = 0; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + payload.pop_back(); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, RejectsMismatchedAddressAndUnsupportedDeviceType) { + Harness h; + constexpr uint8_t other_mac_address[6] = {0x66, 0x44, 0x33, 0x22, 0x11, 0x00}; + EXPECT_FALSE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61, other_mac_address))); + + auto payload = ARANET4_PAYLOAD; + payload.insert(payload.begin(), 0x09); + payload.push_back(0x00); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + // Type 0 is Aranet4, which never sends the 24 byte layout; the second warning stays quiet + payload[0] = 0x00; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, DecodesAranet2) { + Harness h; + int temperature_updates = 0; + h.temperature.add_on_state_callback([&temperature_updates](float) { temperature_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET2_PAYLOAD))); + EXPECT_NEAR(h.temperature.state, 20.45f, 0.001f); + EXPECT_FLOAT_EQ(h.humidity.state, 52.2f); + EXPECT_FLOAT_EQ(h.battery.state, 59.0f); + EXPECT_FLOAT_EQ(h.interval.state, 120.0f); + EXPECT_FLOAT_EQ(h.age.state, 82.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + + auto repeated_payload = ARANET2_PAYLOAD; + repeated_payload[10] = 0x00; + repeated_payload[11] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(repeated_payload))); + EXPECT_EQ(temperature_updates, 1); +} + +TEST(Aranet, DecodesAranetRadiation) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADIATION_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radiation_rate.state, 0.13f); + EXPECT_FLOAT_EQ(h.radiation_total.state, 13264.0f); + EXPECT_FLOAT_EQ(h.radiation_duration.state, 417900.0f); + EXPECT_FLOAT_EQ(h.battery.state, 99.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 88.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + EXPECT_FALSE(h.radon.has_state()); +} + +TEST(Aranet, DecodesAranetRadon) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADON_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 25.5f); + EXPECT_FLOAT_EQ(h.humidity.state, 46.2f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, RadonOneSkipsZeroTemperatureAndHumidity) { + Harness h; + auto payload = RADON_PAYLOAD; + payload[10] = payload[11] = 0x00; + payload[14] = payload[15] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); +} + +} // namespace +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/test.esp32-idf.yaml b/tests/components/aranet/test.esp32-idf.yaml new file mode 100644 index 0000000000..ca7354799f --- /dev/null +++ b/tests/components/aranet/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + aranet: !include common.yaml diff --git a/tests/components/aranet/test.ln882x-ard.yaml b/tests/components/aranet/test.ln882x-ard.yaml new file mode 100644 index 0000000000..9c195fc567 --- /dev/null +++ b/tests/components/aranet/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + aranet: !include common-ln.yaml diff --git a/tests/components/aranet/validate.bk72xx-ard.yaml b/tests/components/aranet/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..97a2fe61b2 --- /dev/null +++ b/tests/components/aranet/validate.bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: aranet + ble_hub_id: ble_hub + mac_address: 00:11:22:33:44:55 + co2: + name: BK Aranet4 Carbon Dioxide diff --git a/tests/components/bh1745/common.yaml b/tests/components/bh1745/common.yaml new file mode 100644 index 0000000000..bad906653a --- /dev/null +++ b/tests/components/bh1745/common.yaml @@ -0,0 +1,27 @@ +bh1745: + id: bh1745_hub + i2c_id: i2c_bus + gain: 16X + integration_time: 320ms + glass_attenuation_factor: 2.0 + +sensor: + - platform: bh1745 + bh1745_id: bh1745_hub + red_channel: + name: Red channel + green_channel: + name: Green channel + blue_channel: + name: Blue channel + clear_channel: + name: Clear channel + illuminance: + name: Illuminance + color_temperature: + name: Color temperature + +output: + - platform: bh1745 + id: bh1745_output + bh1745_id: bh1745_hub diff --git a/tests/components/bh1745/test.esp32-idf.yaml b/tests/components/bh1745/test.esp32-idf.yaml new file mode 100644 index 0000000000..f7aff8bedd --- /dev/null +++ b/tests/components/bh1745/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bh1745: !include common.yaml diff --git a/tests/components/bh1745/test.esp8266-ard.yaml b/tests/components/bh1745/test.esp8266-ard.yaml new file mode 100644 index 0000000000..7132464c43 --- /dev/null +++ b/tests/components/bh1745/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bh1745: !include common.yaml diff --git a/tests/components/bl0939/common.yaml b/tests/components/bl0939/common.yaml index a47aa05606..0785fae029 100644 --- a/tests/components/bl0939/common.yaml +++ b/tests/components/bl0939/common.yaml @@ -16,3 +16,5 @@ sensor: name: BL0939 Energy 2 energy_total: name: BL0939 Total energy + internal_temperature: + name: BL0939 Internal Temperature diff --git a/tests/components/bl0939/test-ct.esp32-idf.yaml b/tests/components/bl0939/test-ct.esp32-idf.yaml new file mode 100644 index 0000000000..8401f22163 --- /dev/null +++ b/tests/components/bl0939/test-ct.esp32-idf.yaml @@ -0,0 +1,26 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +sensor: + - platform: bl0939 + mode: current_transformer + voltage: + name: BL0939 Voltage + current_1: + name: BL0939 Current 1 + current_2: + name: BL0939 Current 2 + active_power_1: + name: BL0939 Active Power 1 + active_power_2: + name: BL0939 Active Power 2 + energy_1: + name: BL0939 Energy 1 + energy_2: + name: BL0939 Energy 2 + energy_total: + name: BL0939 Total energy diff --git a/tests/components/bmi270/common-plain.yaml b/tests/components/bmi270/common-plain.yaml new file mode 100644 index 0000000000..13091521a8 --- /dev/null +++ b/tests/components/bmi270/common-plain.yaml @@ -0,0 +1,11 @@ +motion: + - platform: bmi270 + id: plain_imu + i2c_id: i2c_bus + address: 0x69 + +sensor: + - platform: bmi270 + bmi270_id: plain_imu + type: temperature + name: Plain IMU Temperature diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 41f30fd5c6..390def04a1 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -1,7 +1,21 @@ sensor: - platform: bmi270 + bmi270_id: bmi270_motion name: "BMI270 Temperature" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_x + name: "BMM150 Magnetic Field X" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_y + name: "BMM150 Magnetic Field Y" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_z + name: "BMM150 Magnetic Field Z" + - platform: motion motion_id: bmi270_motion type: acceleration_x @@ -75,6 +89,8 @@ motion: # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | # 400HZ | 800HZ | 1600HZ | 3200HZ gyroscope_odr: 200HZ + aux_device: BMM150 + aux_address: 0x10 axis_map: x: y y: x diff --git a/tests/components/bmi270/test-plain.esp32-idf.yaml b/tests/components/bmi270/test-plain.esp32-idf.yaml new file mode 100644 index 0000000000..aafcb7af3a --- /dev/null +++ b/tests/components/bmi270/test-plain.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bmi270: !include common-plain.yaml diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml index b47e39c389..63ea3c5a1a 100644 --- a/tests/components/bmi270/test.esp32-idf.yaml +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bmi270: !include common.yaml + bmi270_plain: !include common-plain.yaml diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml new file mode 100644 index 0000000000..295a68d262 --- /dev/null +++ b/tests/components/bq25186/common.yaml @@ -0,0 +1,99 @@ +esphome: + on_boot: + priority: -100 + then: + # Just the defined configuration + - bq25186.apply_configuration: bq25186_charger + # Just the defined configuration with some overrides + - bq25186.apply_configuration: + id: bq25186_charger + battery_regulation_voltage: 4.1V + fast_charge_current: 200mA + +bq25186: + - id: bq25186_charger + configure_on_boot: false + fast_charge_current: 150mA + battery_regulation_voltage: 4.2V + input_current_limit: 500ma + system_regulation: 4.5v + charge_enabled: true + # Second charger with the boot-time write enabled + - id: bq25186_boot + address: 0x6B + configure_on_boot: true + fast_charge_current: 20mA + watchdog: 160s_software_reset + update_interval: 30s + +binary_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + ts: + name: TS Open + charging: + name: Charging + charge_done: + name: Charge Done + ilim: + name: ILIM Active + vdppm: + name: VDPPM Active + vindpm: + name: VINDPM Active + thermreg: + name: Thermal Regulation Active + power_good: + name: VIN Power Good + vin_ovp: + name: VIN OVP Active + battery_uvlo: + name: Battery UVLO Active + safety_timer_flag: + name: Safety Timer Flag + wake1_flag: + name: Wake 1 Flag + wake2_flag: + name: Wake 2 Flag + ts_flag: + name: TS Flag + ilim_flag: + name: ILIM Flag + vdppm_flag: + name: VDPPM Flag + vindpm_flag: + name: VINDPM Flag + thermreg_flag: + name: Thermal Regulation Flag + vin_ovp_flag: + name: VIN OVP Flag + battery_uvlo_flag: + name: Battery UVLO Flag + battery_ocp_flag: + name: Battery OCP Flag + +text_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + charge_status: + name: Charge Status + ts_status: + name: TS Status + +button: + - platform: bq25186 + bq25186_id: bq25186_charger + software_reset: + name: Software Reset + shutdown: + name: Shutdown + ship_mode: + name: Ship Mode + hardware_reset: + name: Hardware Reset + +switch: + - platform: bq25186 + bq25186_id: bq25186_boot + pg_gpo: + name: PG GPO diff --git a/tests/components/bq25186/test.esp32-idf.yaml b/tests/components/bq25186/test.esp32-idf.yaml new file mode 100644 index 0000000000..8854333e24 --- /dev/null +++ b/tests/components/bq25186/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq25186: !include common.yaml diff --git a/tests/components/bq25186/test.esp8266-ard.yaml b/tests/components/bq25186/test.esp8266-ard.yaml new file mode 100644 index 0000000000..88ac37a74f --- /dev/null +++ b/tests/components/bq25186/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq25186: !include common.yaml diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml new file mode 100644 index 0000000000..99d1e1f7f8 --- /dev/null +++ b/tests/components/bq27220/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: bq27220 + id: bq27220_id + voltage: + name: "Battery Voltage" + current: + name: "Battery Current" + battery_level: + name: "Battery" + temperature: + name: "Battery Temperature" + remaining_capacity: + name: "Battery Remaining" + full_charge_capacity: + name: "Battery Full Capacity" + time_to_empty: + name: "Time to Empty" + state_of_health: + name: "Battery Health" diff --git a/tests/components/bq27220/test.esp32-idf.yaml b/tests/components/bq27220/test.esp32-idf.yaml new file mode 100644 index 0000000000..fe4dd41cef --- /dev/null +++ b/tests/components/bq27220/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq27220: !include common.yaml diff --git a/tests/components/climate/common-triggers.yaml b/tests/components/climate/common-triggers.yaml new file mode 100644 index 0000000000..b79d871f0f --- /dev/null +++ b/tests/components/climate/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_state and on_control on a thermostat. +sensor: + - platform: template + id: climate_triggers_temperature + +climate: + - platform: thermostat + name: Climate Triggers + id: climate_triggers + sensor: climate_triggers_temperature + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + on_state: + - logger.log: + format: "Climate mode %d" + args: [x.mode] + on_control: + - lambda: |- + if (x.get_mode().has_value()) + ESP_LOGD("test", "Control mode %d", *x.get_mode()); diff --git a/tests/components/climate/test-triggers.esp32-idf.yaml b/tests/components/climate/test-triggers.esp32-idf.yaml new file mode 100644 index 0000000000..bbf01242bd --- /dev/null +++ b/tests/components/climate/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + climate: !include common.yaml + triggers: !include common-triggers.yaml diff --git a/tests/components/core/test_action_branch_owner.cpp b/tests/components/core/test_action_branch_owner.cpp new file mode 100644 index 0000000000..126f932686 --- /dev/null +++ b/tests/components/core/test_action_branch_owner.cpp @@ -0,0 +1,403 @@ +#include +#include + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::core::testing { + +using Log = std::vector; + +// Records its id when it runs, then continues the chain +class RecordAction : public Action<> { + public: + RecordAction(Log *log, int id) : log_(log), id_(id) {} + void play() override { this->log_->push_back(this->id_); } + Action<> *next() const { return this->next_; } + + protected: + Log *log_; + int id_; +}; + +// Holds the chain until finish() is called, like a delay +class DeferredAction : public Action<> { + public: + explicit DeferredAction(Log *log, int id) : log_(log), id_(id) {} + void play_complex() override { + this->num_running_++; + this->log_->push_back(this->id_); + this->pending_ = true; + } + void play() override {} + void stop() override { this->pending_ = false; } + void finish() { + if (this->pending_) { + this->pending_ = false; + this->play_next_(); + } + } + bool pending() const { return this->pending_; } + + protected: + Log *log_; + int id_; + bool pending_{false}; +}; + +class FlagCondition : public Condition<> { + public: + bool check() override { return this->value; } + bool value{true}; +}; + +// Stops the given list during check(), like script.stop in a lambda condition +class StoppingCondition : public Condition<> { + public: + bool check() override { + if (this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; +}; + +// Stops the given list on the Nth check and keeps returning true +class StopOnCheckCondition : public Condition<> { + public: + explicit StopOnCheckCondition(int stop_on) : stop_on_(stop_on) {} + bool check() override { + if (++this->checks_ == this->stop_on_ && this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; + + protected: + int stop_on_; + int checks_{0}; +}; + +// Turns its condition off after a number of checks +class CountdownCondition : public Condition<> { + public: + explicit CountdownCondition(int passes) : passes_(passes) {} + bool check() override { return this->passes_-- > 0; } + + protected: + int passes_; +}; + +// Reads an action's next_ through Action's own tag helpers +class TaggedOwner : public RecordAction { + public: + using RecordAction::RecordAction; + static bool is_tagged(const TaggedOwner *a) { return a->next_is_owner_(); } + static Action<> *owner_of(const TaggedOwner *a) { return a->owner_(); } +}; + +class InspectList : public ActionList<> { + public: + Action<> *head() const { return this->actions_; } +}; + +TEST(ActionBranchOwner, SetOwnerTagsOnlyTheLastAction) { + Log log; + TaggedOwner a(&log, 1), b(&log, 2), c(&log, 3); + TaggedOwner owner(&log, 99); + InspectList list; + list.add_actions({&a, &b}); + list.add_action(&c); // appending before set_owner keeps order + list.set_owner(&owner); + EXPECT_EQ(list.head(), &a); + EXPECT_EQ(a.next(), &b); + EXPECT_EQ(b.next(), &c); + EXPECT_FALSE(TaggedOwner::is_tagged(&a)); + EXPECT_FALSE(TaggedOwner::is_tagged(&b)); + EXPECT_TRUE(TaggedOwner::is_tagged(&c)); + EXPECT_EQ(TaggedOwner::owner_of(&c), &owner); +} + +TEST(ActionBranchOwner, EmptyListStaysEmpty) { + Log log; + TaggedOwner owner(&log, 99); + InspectList list; + list.set_owner(&owner); + EXPECT_EQ(list.head(), nullptr); + EXPECT_TRUE(list.empty()); +} + +// ESPHOME_DEBUG_ASSERT is only live because script/cpp_unit_test.py builds with -DESPHOME_DEBUG +TEST(ActionBranchOwnerDeathTest, AppendAfterSetOwnerAsserts) { + Log log; + RecordAction a(&log, 1), late(&log, 2); + TaggedOwner owner(&log, 99); + ActionList<> list; + list.add_action(&a); + list.set_owner(&owner); + EXPECT_DEATH(list.add_action(&late), ""); +} + +TEST(ActionBranchOwner, IfThenResumesAfterTheIfOnce) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then({&t1, &t2}); + if_action.add_else({&e1}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4})); + log.clear(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{3, 4})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// The per-item calls automation.py emits +TEST(ActionBranchOwner, PerItemIfAndWhileResumeAfterTheirBranch) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then_action(&t1); + if_action.add_then_action(&t2); + if_action.finish_then(); + if_action.add_else_action(&e1); + if_action.finish_else(); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4, 3, 4})); + + log.clear(); + CountdownCondition loop_cond(2); + WhileAction<> loop(&loop_cond); + DeferredAction wait(&log, 5); + RecordAction after2(&log, 6); + loop.add_then_action(&wait); + loop.finish_then(); + ActionList<> top2; + top2.add_actions({&loop, &after2}); + top2.play(); + wait.finish(); + wait.finish(); + EXPECT_EQ(log, (Log{5, 5, 6})); + EXPECT_FALSE(top2.is_running()); +} + +// then: [] generates finish_then() with no actions +TEST(ActionBranchOwner, PerItemEmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + if_action.finish_then(); + if_action.finish_else(); + WhileAction<> loop(&cond); + loop.finish_then(); + RecordAction after(&log, 1), after2(&log, 2); + ActionList<> top, top2; + top.add_actions({&if_action, &after}); + top2.add_actions({&loop, &after2}); + top.play(); + cond.value = false; + top.play(); + cond.value = true; + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, ConditionThatStopsTheRunStartsNoBranch) { + Log log; + StoppingCondition cond; + IfAction if_action(&cond); + WhileAction<> loop(&cond); + RecordAction t1(&log, 1), e1(&log, 2), body(&log, 3), after(&log, 4); + if_action.add_then({&t1}); + if_action.add_else({&e1}); + loop.add_then({&body}); + ActionList<> if_list, while_list; + if_list.add_actions({&if_action, &after}); + while_list.add_actions({&loop, &after}); + + cond.list = &if_list; + if_list.play(); + cond.list = &while_list; + while_list.play(); + EXPECT_TRUE(log.empty()); + EXPECT_FALSE(if_list.is_running()); + EXPECT_FALSE(while_list.is_running()); +} + +TEST(ActionBranchOwner, WhileConditionStoppingOnALaterPassEndsTheLoop) { + Log log; + StopOnCheckCondition cond(2); + WhileAction<> loop(&cond); + RecordAction body(&log, 1), after(&log, 2); + loop.add_then({&body}); + ActionList<> list; + list.add_actions({&loop, &after}); + cond.list = &list; + + list.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_FALSE(list.is_running()); +} + +TEST(ActionBranchOwner, IfResumesAfterDeferredLastAction) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_TRUE(top.is_running()); + // num_running() counts the top-level chain only: the if, not the action inside its branch + EXPECT_EQ(top.num_running(), 1); + wait.finish(); + EXPECT_EQ(log, (Log{1, 2})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// A deferred action that can hold two runs at once, completed in order +class QueueDeferredAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->pending_++; + } + void play() override {} + void finish_one() { + if (this->pending_ > 0) { + this->pending_--; + this->play_next_(); + } + } + int running() const { return this->num_running_; } + + protected: + int pending_{0}; +}; + +TEST(ActionBranchOwner, OverlappingRunsResumeOncePerRun) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + QueueDeferredAction wait; + RecordAction after(&log, 7); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + top.play(); + EXPECT_EQ(wait.running(), 2); // the tagged last action holds both runs + wait.finish_one(); + EXPECT_EQ(log, (Log{7})); + EXPECT_TRUE(top.is_running()); + wait.finish_one(); + EXPECT_EQ(log, (Log{7, 7})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(wait.running(), 0); +} + +TEST(ActionBranchOwner, EmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction after(&log, 1); + if_action.add_then({}); + if_action.add_else({}); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 1})); + + FlagCondition always; + WhileAction<> loop(&always); + loop.add_then({}); + ActionList<> top2; + RecordAction after2(&log, 2); + top2.add_actions({&loop, &after2}); + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, WhileRechecksEachPassAndExits) { + Log log; + CountdownCondition cond(3); + WhileAction<> loop(&cond); + RecordAction body(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&body}); + ActionList<> top; + top.add_actions({&loop, &after}); + top.play(); + EXPECT_EQ(log, (Log{1, 1, 1, 2})); + EXPECT_FALSE(top.is_running()); +} + +TEST(ActionBranchOwner, StopMidBranchClearsRunning) { + Log log; + FlagCondition cond; + WhileAction<> loop(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&wait}); + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_TRUE(top.is_running()); + top.stop(); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); + wait.finish(); // already stopped: nothing pending, nothing resumes + EXPECT_EQ(log, (Log{1})); + + // A fresh run works after the stop + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2})); +} + +TEST(ActionBranchOwner, NestedIfInsideWhileResumesEachOwner) { + Log log; + CountdownCondition loop_cond(2); + FlagCondition if_cond; + WhileAction<> loop(&loop_cond); + IfAction inner(&if_cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 9); + inner.add_then({&wait}); + loop.add_then({&inner}); // the if is the last action of the loop body + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + wait.finish(); // resumes the if, then the while, which loops again + EXPECT_EQ(log, (Log{1, 1})); + wait.finish(); // second pass ends; the while condition is now false + EXPECT_EQ(log, (Log{1, 1, 9})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +} // namespace esphome::core::testing diff --git a/tests/components/core/test_blocking_scope.cpp b/tests/components/core/test_blocking_scope.cpp new file mode 100644 index 0000000000..8bdeec19ea --- /dev/null +++ b/tests/components/core/test_blocking_scope.cpp @@ -0,0 +1,103 @@ +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome { + +// The scope must push the pass start forward by the time it covers and by +// nothing else, so the blocking guard sees only the work outside it +TEST(UnavoidableBlockingScope, ExcludesItsDurationFromThePass) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + ASSERT_EQ(App.get_loop_component_start_time(), pass_start); + + const uint32_t before = millis(); + { + UnavoidableBlockingScope scope; + delay(30); + } + const uint32_t excused = millis() - before; + + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 30u); + EXPECT_LE(moved, excused); +} + +TEST(UnavoidableBlockingScope, ZeroLengthScopeLeavesTheStartAlone) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { UnavoidableBlockingScope scope; } + EXPECT_LE(App.get_loop_component_start_time() - pass_start, millis() - before); +} + +// Nested scopes leave out the outer span exactly once, and never move the +// start past now +TEST(UnavoidableBlockingScope, NestedScopesExcuseTheOuterSpanOnce) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { + UnavoidableBlockingScope outer; + { + UnavoidableBlockingScope inner; + delay(30); + } + delay(5); + } + const uint32_t excused = millis() - before; + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 35u); + EXPECT_LE(moved, excused); + EXPECT_GE(static_cast(millis() - App.get_loop_component_start_time()), 0); +} + +namespace { +// Static: the guard publishes the component to App and nothing clears it. +// One instance per test, since a ratcheted threshold is permanent +class DummyComponent : public Component {}; +DummyComponent &blocking_test_component(size_t index) { + static DummyComponent components[2]; + return components[index]; +} +} // namespace + +// The excused stretch must neither warn nor ratchet the component's threshold +TEST(UnavoidableBlockingScope, ExcusedStretchDoesNotRatchetTheThreshold) { + DummyComponent &component = blocking_test_component(0); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + } + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_EQ(threshold_after, threshold_before); +} + +// Work outside the scope is still measured and still ratchets +TEST(UnavoidableBlockingScope, WorkOutsideTheScopeStillRatchetsTheThreshold) { + DummyComponent &component = blocking_test_component(1); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(20); + } + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_GT(threshold_after, threshold_before); +} + +} // namespace esphome diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 9fee0c723b..ac71cf8232 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -7,6 +7,47 @@ namespace esphome::core::testing { +// --- truncate_name_to() --- + +TEST(TruncateNameTo, NameThatFitsIsCopied) { + char buffer[21]; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), "kitchen", 7, 7), 7u); + EXPECT_STREQ(buffer, "kitchen"); +} + +TEST(TruncateNameTo, NameOfExactlyTheLimitIsCopied) { + char buffer[8]; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), "kitchen", 7, 0), 7u); + EXPECT_STREQ(buffer, "kitchen"); +} + +TEST(TruncateNameTo, CutsTheEndWithoutASuffix) { + char buffer[21]; + const char *name = "a-very-long-device-name-here"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 0), 20u); + EXPECT_STREQ(buffer, "a-very-long-device-n"); +} + +TEST(TruncateNameTo, KeepsTheSuffixAndRemovesTheMiddle) { + char buffer[21]; + const char *name = "a-very-long-device-name-a1b2c3"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 7), 20u); + EXPECT_STREQ(buffer, "a-very-long-d-a1b2c3"); +} + +TEST(TruncateNameTo, SuffixLongerThanTheBufferIsCut) { + char buffer[5]; + const char *name = "name-a1b2c3"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 7), 4u); + EXPECT_STREQ(buffer, "b2c3"); +} + +TEST(TruncateNameTo, ZeroBufferSize) { + char buffer[1] = {'x'}; + EXPECT_EQ(truncate_name_to(buffer, 0, "name", 4, 0), 0u); + EXPECT_EQ(buffer[0], 'x'); +} + // --- format_hex_to() --- TEST(FormatHexTo, Basic) { diff --git a/tests/components/cse7761/common-current-transformer.yaml b/tests/components/cse7761/common-current-transformer.yaml new file mode 100644 index 0000000000..6c5854d8af --- /dev/null +++ b/tests/components/cse7761/common-current-transformer.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: cse7761 + current_input: current_transformer + voltage: + name: CSE7761 CT Voltage + current_1: + name: CSE7761 CT Current 1 + active_power_1: + name: CSE7761 CT Active Power 1 + energy_1: + name: CSE7761 CT Energy 1 diff --git a/tests/components/cse7761/test.esp32-idf.yaml b/tests/components/cse7761/test.esp32-idf.yaml index b9ae061c25..c0c97c2bec 100644 --- a/tests/components/cse7761/test.esp32-idf.yaml +++ b/tests/components/cse7761/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/test.esp8266-ard.yaml b/tests/components/cse7761/test.esp8266-ard.yaml index 0d57039e1c..d1d376eebc 100644 --- a/tests/components/cse7761/test.esp8266-ard.yaml +++ b/tests/components/cse7761/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp8266-ard.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/test.rp2040-ard.yaml b/tests/components/cse7761/test.rp2040-ard.yaml index 65e6252c51..3850be0070 100644 --- a/tests/components/cse7761/test.rp2040-ard.yaml +++ b/tests/components/cse7761/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/rp2040-ard.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml b/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml new file mode 100644 index 0000000000..9ad82dcf96 --- /dev/null +++ b/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +packages: + uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml + cse7761: !include common-current-transformer.yaml diff --git a/tests/components/daikin_brc/common.yaml b/tests/components/daikin_brc/common.yaml index 89786954ba..0e363cd156 100644 --- a/tests/components/daikin_brc/common.yaml +++ b/tests/components/daikin_brc/common.yaml @@ -2,3 +2,7 @@ climate: - platform: daikin_brc name: Daikin_brc Climate transmitter_id: xmitr + - platform: daikin_brc + name: Daikin_brc Climate Unit 2 + transmitter_id: xmitr + sub_unit: 1 diff --git a/tests/components/daikin_brc/test.esp32-idf.yaml b/tests/components/daikin_brc/test.esp32-idf.yaml index e891f9dc85..9761ee9ede 100644 --- a/tests/components/daikin_brc/test.esp32-idf.yaml +++ b/tests/components/daikin_brc/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + daikin_brc: !include common.yaml diff --git a/tests/components/daikin_brc/test.esp8266-ard.yaml b/tests/components/daikin_brc/test.esp8266-ard.yaml index 4bed2f03e5..6140ccb881 100644 --- a/tests/components/daikin_brc/test.esp8266-ard.yaml +++ b/tests/components/daikin_brc/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + daikin_brc: !include common.yaml diff --git a/tests/components/ds3231/common.yaml b/tests/components/ds3231/common.yaml new file mode 100644 index 0000000000..9f087cb941 --- /dev/null +++ b/tests/components/ds3231/common.yaml @@ -0,0 +1,15 @@ +ds3231: + - id: ds3231_component + i2c_id: i2c_bus + address: 0x68 + +sensor: + - platform: ds3231 + ds3231_id: ds3231_component + name: DS3231 Temperature + update_interval: never + +interval: + - interval: 60s + then: + - ds3231.force_temperature_conversion: ds3231_component diff --git a/tests/components/ds3231/test.esp32-idf.yaml b/tests/components/ds3231/test.esp32-idf.yaml new file mode 100644 index 0000000000..fb7ac2fba5 --- /dev/null +++ b/tests/components/ds3231/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ds3231: !include common.yaml diff --git a/tests/components/ds3231/test.esp8266-ard.yaml b/tests/components/ds3231/test.esp8266-ard.yaml new file mode 100644 index 0000000000..ca42d95738 --- /dev/null +++ b/tests/components/ds3231/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds3231: !include common.yaml diff --git a/tests/components/dsmr/test.esp32-idf.yaml b/tests/components/dsmr/test.esp32-idf.yaml index 9eb7d3e178..4e8224226f 100644 --- a/tests/components/dsmr/test.esp32-idf.yaml +++ b/tests/components/dsmr/test.esp32-idf.yaml @@ -12,6 +12,66 @@ sensor: name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" energy_delivered_tariff1: name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" + energy_delivered_ch: + name: "Energy Consumed (CH). OBIS: 1-1:1.8.0" + energy_returned_ch: + name: "Energy Produced (CH). OBIS: 1-1:2.8.0" + total_imported_energy_ch: + name: "Reactive Energy Imported (CH). OBIS: 1-1:3.8.0" + total_exported_energy_ch: + name: "Reactive Energy Exported (CH). OBIS: 1-1:4.8.0" + reactive_energy_q1_ch: + name: "Reactive Energy Q1 (CH). OBIS: 1-1:5.8.0" + reactive_energy_q1_tariff1_ch: + name: "Reactive Energy Q1 Tariff 1 (CH). OBIS: 1-1:5.8.1" + reactive_energy_q1_tariff2_ch: + name: "Reactive Energy Q1 Tariff 2 (CH). OBIS: 1-1:5.8.2" + reactive_energy_q2_ch: + name: "Reactive Energy Q2 (CH). OBIS: 1-1:6.8.0" + reactive_energy_q2_tariff1_ch: + name: "Reactive Energy Q2 Tariff 1 (CH). OBIS: 1-1:6.8.1" + reactive_energy_q2_tariff2_ch: + name: "Reactive Energy Q2 Tariff 2 (CH). OBIS: 1-1:6.8.2" + reactive_energy_q3_ch: + name: "Reactive Energy Q3 (CH). OBIS: 1-1:7.8.0" + reactive_energy_q3_tariff1_ch: + name: "Reactive Energy Q3 Tariff 1 (CH). OBIS: 1-1:7.8.1" + reactive_energy_q3_tariff2_ch: + name: "Reactive Energy Q3 Tariff 2 (CH). OBIS: 1-1:7.8.2" + reactive_energy_q4_ch: + name: "Reactive Energy Q4 (CH). OBIS: 1-1:8.8.0" + reactive_energy_q4_tariff1_ch: + name: "Reactive Energy Q4 Tariff 1 (CH). OBIS: 1-1:8.8.1" + reactive_energy_q4_tariff2_ch: + name: "Reactive Energy Q4 Tariff 2 (CH). OBIS: 1-1:8.8.2" + power_delivered_l1_ch: + name: "Power Consumed L1 (CH). OBIS: 1-1:21.7.0" + power_delivered_l2_ch: + name: "Power Consumed L2 (CH). OBIS: 1-1:41.7.0" + power_delivered_l3_ch: + name: "Power Consumed L3 (CH). OBIS: 1-1:61.7.0" + power_returned_l1_ch: + name: "Power Produced L1 (CH). OBIS: 1-1:22.7.0" + power_returned_l2_ch: + name: "Power Produced L2 (CH). OBIS: 1-1:42.7.0" + power_returned_l3_ch: + name: "Power Produced L3 (CH). OBIS: 1-1:62.7.0" + reactive_power_delivered_l1_ch: + name: "Reactive Power Consumed L1 (CH). OBIS: 1-1:23.7.0" + reactive_power_delivered_l2_ch: + name: "Reactive Power Consumed L2 (CH). OBIS: 1-1:43.7.0" + reactive_power_delivered_l3_ch: + name: "Reactive Power Consumed L3 (CH). OBIS: 1-1:63.7.0" + reactive_power_returned_l1_ch: + name: "Reactive Power Produced L1 (CH). OBIS: 1-1:24.7.0" + reactive_power_returned_l2_ch: + name: "Reactive Power Produced L2 (CH). OBIS: 1-1:44.7.0" + reactive_power_returned_l3_ch: + name: "Reactive Power Produced L3 (CH). OBIS: 1-1:64.7.0" + maximum_power_delivered_ch: + name: "Maximum Power Consumed (CH). OBIS: 1-1:1.6.0" + maximum_power_returned_ch: + name: "Maximum Power Produced (CH). OBIS: 1-1:2.6.0" text_sensor: - platform: dsmr diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml new file mode 100644 index 0000000000..84fd33a983 --- /dev/null +++ b/tests/components/ecocomfort2/common.yaml @@ -0,0 +1,59 @@ +esp32_ble: + io_capability: none + auth_req_mode: sc_bond + max_key_size: 16 + +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + id: sntp_time + +ble_client: + - mac_address: 01:02:03:04:05:06 + id: ecocomfort2_ble_1 + - mac_address: 01:02:03:04:05:07 + id: ecocomfort2_ble_2 + +ecocomfort2: + - id: ecocomfort2_hub_1 + ble_client_id: ecocomfort2_ble_1 + time_id: sntp_time + - id: ecocomfort2_hub_2 + ble_client_id: ecocomfort2_ble_2 + +fan: + - platform: ecocomfort2 + name: Ecocomfort2 Fan 1 + ecocomfort2_id: ecocomfort2_hub_1 + - platform: ecocomfort2 + name: Ecocomfort2 Fan 2 + ecocomfort2_id: ecocomfort2_hub_2 + +sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + temperature: + name: Ecocomfort2 Temperature + humidity: + name: Ecocomfort2 Humidity + voc: + name: Ecocomfort2 VOC + direction: + name: Ecocomfort2 Direction + +binary_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + connected: + name: Ecocomfort2 Connected + boost: + name: Ecocomfort2 Boost + +text_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_2 + version: + name: Ecocomfort2 Firmware diff --git a/tests/components/ecocomfort2/test.esp32-idf.yaml b/tests/components/ecocomfort2/test.esp32-idf.yaml new file mode 100644 index 0000000000..956cd9c813 --- /dev/null +++ b/tests/components/ecocomfort2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + ecocomfort2: !include common.yaml diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 5c25e37abb..e609a217e0 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,6 +8,7 @@ button: emontx: id: test_emontx + lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 8530b0a003..5d9fa09611 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -158,4 +158,37 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { EXPECT_EQ(this->lines_[0], "{\"P1\":2}"); } +TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.set_lock_on_reboot(true); + this->sut_.on_shutdown(); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.set_lock_on_reboot(true); + this->sut_.set_paused(true); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); +} + +TEST_F(EmonTxTest, SetupSendsNothingByDefault) { + this->sut_.setup(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8cb2a99521..3839b4ff87 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -234,6 +234,29 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey0213f51-2.13 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 255, 0)); + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) - platform: epaper_spi spi_id: spi_bus diff --git a/tests/components/es7210/test.esp32-s3-idf.yaml b/tests/components/es7210/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..26409ccdae --- /dev/null +++ b/tests/components/es7210/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + +audio_adc: + - platform: es7210 + i2c_id: i2c_bus + id: es7210_adc + bits_per_sample: 16bit + channel_gains: [33db, 33db, 0db, 0db] + sample_rate: 16000 + tdm: true diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml new file mode 100644 index 0000000000..78d64c634a --- /dev/null +++ b/tests/components/esp32_power_management/common.yaml @@ -0,0 +1,11 @@ +power_management: + - platform: esp32_power_management + id: pm_id + max_frequency: ${esp32_power_management_max_frequency} + min_frequency: ${esp32_power_management_min_frequency} + enable_light_sleep: true + idle_time_before_sleep: 3 + power_down_flash: ${esp32_power_management_power_down_flash} + power_down_peripherals: ${esp32_power_management_power_down_peripherals} + profiling: true + trace: true diff --git a/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml new file mode 100644 index 0000000000..364ad5329d --- /dev/null +++ b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "true" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml new file mode 100644 index 0000000000..513617a53b --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml new file mode 100644 index 0000000000..7bf893aeff --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 96MHZ + esp32_power_management_min_frequency: 32MHZ + esp32_power_management_power_down_peripherals: "true" + esp32_power_management_power_down_flash: "false" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-idf.yaml b/tests/components/esp32_power_management/test.esp32-idf.yaml new file mode 100644 index 0000000000..513617a53b --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp new file mode 100644 index 0000000000..5ee22ae9d4 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include +#include + +#include "esphome/components/ezsp_proxy_tap/ash_acknowledger.h" +#include "esphome/components/ezsp_proxy_tap/ash_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap::testing { + +namespace { + +// Whole frames as they appear on the wire, each ending in its FLAG. RSTACK and ACK(1) +// are the examples in Silicon Labs UG101. +const std::vector RSTACK = {0xC1, 0x02, 0x02, 0x9B, 0x7B, 0x7E}; +const std::vector DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0x50, 0x9B, 0x98, 0x7E}; +const std::vector DATA_FRAME_1 = {0x10, 0x43, 0x21, 0xA8, 0x50, 0x9F, 0xC2, 0x7E}; +const std::vector DATA_FRAME_0_RETRANSMITTED = {0x08, 0x43, 0x21, 0xA8, 0x50, 0x99, 0xB5, 0x7E}; +const std::vector DATA_FRAME_5 = {0x50, 0x43, 0x21, 0xA8, 0x50, 0x8E, 0xAA, 0x7E}; +const std::vector DATA_FRAME_5_RETRANSMITTED = {0x58, 0x43, 0x21, 0xA8, 0x50, 0x8C, 0x87, 0x7E}; +const std::vector DATA_FRAME_6 = {0x60, 0x43, 0x21, 0xA8, 0x50, 0x82, 0x44, 0x7E}; +// Data frame 0 with a 2-byte and a 3-byte data field, below and at the minimum +const std::vector SHORT_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA0, 0x40, 0x7E}; +const std::vector MIN_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0xC1, 0x08, 0x7E}; +// Body {0x00, 0x7E, 0x7D, 0x11, 0x13, 0x18, 0x1A}: every reserved byte, each escaped +const std::vector ESCAPED_FRAME = {0x00, 0x7D, 0x5E, 0x7D, 0x5D, 0x7D, 0x31, 0x7D, + 0x33, 0x7D, 0x38, 0x7D, 0x3A, 0x46, 0x8C, 0x7E}; + +const std::vector RESERVED_BYTES = {ASH_FLAG_BYTE, ASH_ESCAPE_BYTE, ASH_XON_BYTE, + ASH_XOFF_BYTE, ASH_SUBSTITUTE_BYTE, ASH_CANCEL_BYTE}; + +// Data frame 0 with `data_size` bytes of 0x42 and a valid CRC, none of which needs escaping +std::vector unstuffed_frame(size_t data_size) { + std::vector frame(1 + data_size, 0x42); + frame[0] = 0x00; + const uint16_t crc = crc16be(frame.data(), frame.size(), ASH_CRC_INIT); + frame.push_back(crc >> 8); + frame.push_back(crc & 0xFF); + frame.push_back(ASH_FLAG_BYTE); + return frame; +} + +std::vector concat(std::vector a, const std::vector &b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +// Returns whether the last byte completed a valid frame +bool feed(AshFrameScanner &scanner, const std::vector &bytes) { + bool complete = false; + for (uint8_t byte : bytes) + complete = scanner.feed(byte); + return complete; +} + +// Feeds whole frames and returns the ACK owed after the last, or -1 if none is +int feed(AshAcknowledger &acknowledger, const std::vector &bytes) { + bool owed = false; + for (uint8_t byte : bytes) + owed = acknowledger.feed(byte); + return owed ? acknowledger.ack_num() : -1; +} + +} // namespace + +TEST(AshProtocol, AckFrameMatchesReference) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, 1); + EXPECT_EQ(std::vector(frame, frame + sizeof(frame)), (std::vector{0x7E, 0x81, 0x60, 0x59, 0x7E})); +} + +TEST(AshProtocol, AckFramesNeedNoEscaping) { + for (uint8_t ack_num = 0; ack_num <= ASH_MAX_SEQUENCE; ack_num++) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, ack_num); + EXPECT_EQ(frame[0], ASH_FLAG_BYTE); + EXPECT_EQ(frame[4], ASH_FLAG_BYTE); + for (size_t i = 1; i < 4; i++) { + EXPECT_EQ(std::count(RESERVED_BYTES.begin(), RESERVED_BYTES.end(), frame[i]), 0) + << "ack_num " << int(ack_num) << " byte " << i; + } + } +} + +TEST(AshFrameScanner, UnescapesReservedBytes) { + AshFrameScanner scanner; + // The CRC covers the unescaped body, so a valid frame proves every byte was restored + ASSERT_TRUE(feed(scanner, ESCAPED_FRAME)); + EXPECT_EQ(scanner.length(), 7u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x7E})); +} + +TEST(AshFrameScanner, RejectsBadCrc) { + AshFrameScanner scanner; + std::vector corrupt = DATA_FRAME_0; + corrupt[corrupt.size() - 2] ^= 0x01; + EXPECT_FALSE(feed(scanner, corrupt)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelDiscardsPartialFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, SubstituteDiscardsUntilFlag) { + AshFrameScanner scanner; + std::vector substituted = DATA_FRAME_0; + substituted.insert(substituted.begin() + 2, ASH_SUBSTITUTE_BYTE); + EXPECT_FALSE(feed(scanner, substituted)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelEndsSubstitute) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, ASH_SUBSTITUTE_BYTE, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); +} + +TEST(AshFrameScanner, IgnoresWakeBetweenFrames) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({ASH_WAKE_BYTE, ASH_FLAG_BYTE, ASH_WAKE_BYTE, ASH_WAKE_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, IgnoresFlowControlInsideFrame) { + AshFrameScanner scanner; + std::vector interrupted = DATA_FRAME_0; + interrupted.insert(interrupted.begin() + 3, ASH_XOFF_BYTE); + interrupted.insert(interrupted.begin() + 1, ASH_XON_BYTE); + ASSERT_TRUE(feed(scanner, interrupted)); + EXPECT_EQ(scanner.length(), 5u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x43})); +} + +TEST(AshFrameScanner, RejectsOversizedFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE))); + EXPECT_EQ(scanner.length(), 1 + ASH_MAX_DATA_FIELD_SIZE); + EXPECT_FALSE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE + 1))); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshAcknowledger, AcknowledgesFramesInSequence) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + // A repeat not marked as a retransmission is out of sequence + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), -1); + // A retransmission means the NCP missed our ACK, so it gets the same one again + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0_RETRANSMITTED), 1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_1), 2); +} + +TEST(AshAcknowledger, RstackRestartsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + EXPECT_EQ(feed(acknowledger, RSTACK), -1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, IgnoresShortDataFrames) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, SHORT_DATA_FRAME_0), -1); + EXPECT_EQ(feed(acknowledger, MIN_DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, SyncsToFirstFrameMidSession) { + AshAcknowledger acknowledger; + // The NCP is retransmitting a frame nobody acknowledged before the tap was watching + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5_RETRANSMITTED), 6); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_6), 7); +} + +TEST(AshAcknowledger, ResetForgetsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + acknowledger.reset(); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5), 6); +} + +} // namespace esphome::ezsp_proxy_tap::testing diff --git a/tests/components/ezsp_proxy_tap/common.yaml b/tests/components/ezsp_proxy_tap/common.yaml new file mode 100644 index 0000000000..64391dc080 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/common.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +# The port owns the UART and carries every byte; ezsp_proxy_tap only taps it +serial_proxy: + - id: ezsp_serial + name: Zigbee + port_type: TTL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_serial diff --git a/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml new file mode 100644 index 0000000000..4c64260e86 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,27 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +usb_host: + +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: ezsp_usb_channel + baud_rate: 460800 + +# The tapped port may be a USB CDC ACM channel just as well as a hardware UART: +# ezsp_proxy_tap never touches the UART itself, so it does not care which it is. +serial_proxy: + - id: ezsp_usb_serial + uart_id: ezsp_usb_channel + name: Zigbee + port_type: USB_SERIAL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_usb_serial diff --git a/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml new file mode 100644 index 0000000000..afdbf0e676 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml new file mode 100644 index 0000000000..fd21fecf7f --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml new file mode 100644 index 0000000000..a979e3fda1 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/fan/common-triggers.yaml b/tests/components/fan/common-triggers.yaml new file mode 100644 index 0000000000..d64f7b5e82 --- /dev/null +++ b/tests/components/fan/common-triggers.yaml @@ -0,0 +1,35 @@ +# on_state with the fan as trigger argument, plus the edge and set triggers. +fan: + - platform: template + name: Fan Triggers + id: fan_triggers + preset_modes: + - Eco + - Turbo + has_oscillating: true + has_direction: true + speed_count: 3 + on_state: + - logger.log: + format: "Fan is %s" + args: ['x->state ? "on" : "off"'] + on_turn_on: + - logger.log: Fan turned on + on_turn_off: + - logger.log: Fan turned off + on_direction_set: + - logger.log: + format: "Direction %d" + args: ["(int) x"] + on_oscillating_set: + - logger.log: + format: "Oscillating %d" + args: ["x"] + on_speed_set: + - logger.log: + format: "Speed %d" + args: ["x"] + on_preset_set: + - logger.log: + format: "Preset %s" + args: ["x.c_str()"] diff --git a/tests/components/fan/test-triggers.esp8266-ard.yaml b/tests/components/fan/test-triggers.esp8266-ard.yaml new file mode 100644 index 0000000000..0cfba2d491 --- /dev/null +++ b/tests/components/fan/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + fan: !include common.yaml + triggers: !include common-triggers.yaml diff --git a/tests/components/heatpumpir/common.yaml b/tests/components/heatpumpir/common.yaml index a2779f9803..de45f91ec9 100644 --- a/tests/components/heatpumpir/common.yaml +++ b/tests/components/heatpumpir/common.yaml @@ -7,6 +7,23 @@ climate: min_temperature: 18 max_temperature: 30 transmitter_id: xmitr + - platform: heatpumpir + protocol: mitsubishi_heavy_zj + horizontal_default: left + vertical_default: mup + name: HeatpumpIR Climate Mitsubishi ZJ + min_temperature: 18 + max_temperature: 30 + transmitter_id: xmitr + - platform: heatpumpir + protocol: mitsubishi_heavy_zmp + horizontal_default: left + vertical_default: up + name: HeatpumpIR Climate Mitsubishi ZMP + min_temperature: 18 + max_temperature: 30 + transmitter_id: xmitr + receiver_id: rcvr - platform: heatpumpir protocol: daikin horizontal_default: mleft diff --git a/tests/components/heatpumpir/test.bk72xx-ard.yaml b/tests/components/heatpumpir/test.bk72xx-ard.yaml index 6cce191825..779adcc307 100644 --- a/tests/components/heatpumpir/test.bk72xx-ard.yaml +++ b/tests/components/heatpumpir/test.bk72xx-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/bk72xx-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/bk72xx-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp32-ard.yaml b/tests/components/heatpumpir/test.esp32-ard.yaml index 01009de071..bd5a74f356 100644 --- a/tests/components/heatpumpir/test.esp32-ard.yaml +++ b/tests/components/heatpumpir/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp32-idf.yaml b/tests/components/heatpumpir/test.esp32-idf.yaml index e891f9dc85..3c81acdbab 100644 --- a/tests/components/heatpumpir/test.esp32-idf.yaml +++ b/tests/components/heatpumpir/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp8266-ard.yaml b/tests/components/heatpumpir/test.esp8266-ard.yaml index 4bed2f03e5..5449d62be4 100644 --- a/tests/components/heatpumpir/test.esp8266-ard.yaml +++ b/tests/components/heatpumpir/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/husb238/common.yaml b/tests/components/husb238/common.yaml new file mode 100644 index 0000000000..94e055acf8 --- /dev/null +++ b/tests/components/husb238/common.yaml @@ -0,0 +1,11 @@ +husb238: + id: husb238_hub + i2c_id: i2c_bus + +binary_sensor: + - platform: husb238 + husb238_id: husb238_hub + attached: "PD Attached" + cc_direction: + name: "CC Direction" + entity_category: diagnostic diff --git a/tests/components/husb238/test.esp32-idf.yaml b/tests/components/husb238/test.esp32-idf.yaml new file mode 100644 index 0000000000..a2286c6e51 --- /dev/null +++ b/tests/components/husb238/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + husb238: !include common.yaml diff --git a/tests/components/husb238/test.esp8266-ard.yaml b/tests/components/husb238/test.esp8266-ard.yaml new file mode 100644 index 0000000000..103b71b662 --- /dev/null +++ b/tests/components/husb238/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + husb238: !include common.yaml diff --git a/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..7331691f4d --- /dev/null +++ b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +light: + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip1 + pin: GPIO1 + num_leds: 100 + channel_colors: GRB + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip2 + pin: GPIO2 + num_leds: 5 + channel_colors: RWGB diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml new file mode 100644 index 0000000000..33b213d363 --- /dev/null +++ b/tests/components/ld2410s/common.yaml @@ -0,0 +1,7 @@ +ld2410s: + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + has_target: + name: Presence diff --git a/tests/components/ld2410s/test.esp32-idf.yaml b/tests/components/ld2410s/test.esp32-idf.yaml new file mode 100644 index 0000000000..a82d5fd343 --- /dev/null +++ b/tests/components/ld2410s/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.esp8266-ard.yaml b/tests/components/ld2410s/test.esp8266-ard.yaml new file mode 100644 index 0000000000..7346cb4670 --- /dev/null +++ b/tests/components/ld2410s/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.rp2040-ard.yaml b/tests/components/ld2410s/test.rp2040-ard.yaml new file mode 100644 index 0000000000..c3969bdf0d --- /dev/null +++ b/tests/components/ld2410s/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2460/common.h b/tests/components/ld2460/common.h new file mode 100644 index 0000000000..e5b19d5ba2 --- /dev/null +++ b/tests/components/ld2460/common.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/ld2460/ld2460.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::ld2460::testing { + +// Mock UART component to satisfy UARTDevice parent requirement. +class MockUARTComponent : public uart::UARTComponent { + public: + MOCK_METHOD(void, write_array, (const uint8_t *data, size_t len), (override)); + MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override)); + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(size_t, available, (), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +class TestableLD2460 : public LD2460Component { + public: + void feed(const std::vector &data) { + for (uint8_t byte : data) { + this->readline_(byte); + } + } +}; + +struct Point { + int16_t x; // 0.1 m steps + int16_t y; +}; + +// Periodic data frame: header (4) + function (1) + length (2) + 4 bytes per target + footer (4) +inline std::vector make_periodic_frame(const std::vector &targets) { + auto len = static_cast(11 + targets.size() * 4); + std::vector frame = { + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, static_cast(len & 0xFF), static_cast(len >> 8)}; + for (const auto &t : targets) { + auto x = static_cast(t.x); + auto y = static_cast(t.y); + frame.insert(frame.end(), {static_cast(x & 0xFF), static_cast(x >> 8), + static_cast(y & 0xFF), static_cast(y >> 8)}); + } + frame.insert(frame.end(), {0xF8, 0xF7, 0xF6, 0xF5}); + return frame; +} + +// One target at (1.5 m, 2.3 m) +inline std::vector make_periodic_frame() { return make_periodic_frame({{15, 23}}); } + +// ACK for the query installation mode command (0x0A); mode 1 = side, 2 = top +inline std::vector make_ack_frame(uint8_t mode) { + return {0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, mode, 0x04, 0x03, 0x02, 0x01}; +} + +} // namespace esphome::ld2460::testing diff --git a/tests/components/ld2460/common.yaml b/tests/components/ld2460/common.yaml new file mode 100644 index 0000000000..398bcbae6e --- /dev/null +++ b/tests/components/ld2460/common.yaml @@ -0,0 +1,20 @@ +ld2460: + - id: ld2460_radar + on_data: + then: + - logger.log: "LD2460 Radar Data Received" + +sensor: + - platform: ld2460 + ld2460_id: ld2460_radar + target_count: + name: Target Count + target_5: + x: + name: Target-5 X + y: + name: Target-5 Y + angle: + name: Target-5 Angle + distance: + name: Target-5 Distance diff --git a/tests/components/ld2460/sensor/ld2460_readline.cpp b/tests/components/ld2460/sensor/ld2460_readline.cpp new file mode 100644 index 0000000000..43144b09f3 --- /dev/null +++ b/tests/components/ld2460/sensor/ld2460_readline.cpp @@ -0,0 +1,183 @@ +#include + +#include "../common.h" + +namespace esphome::ld2460::testing { + +class LD2460ReadlineTest : public ::testing::Test { + protected: + void SetUp() override { + this->ld2460_.set_uart_parent(&this->mock_uart_); + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, ::testing::_)).Times(::testing::AnyNumber()); + this->ld2460_.set_target_count_sensor(&this->count_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + this->ld2460_.set_target_x_sensor(i, &this->x_[i]); + this->ld2460_.set_target_y_sensor(i, &this->y_[i]); + this->ld2460_.set_target_distance_sensor(i, &this->distance_[i]); + this->ld2460_.set_target_angle_sensor(i, &this->angle_[i]); + } + } + + MockUARTComponent mock_uart_; + TestableLD2460 ld2460_; + sensor::Sensor count_; + sensor::Sensor x_[MAX_TARGETS]; + sensor::Sensor y_[MAX_TARGETS]; + sensor::Sensor distance_[MAX_TARGETS]; + sensor::Sensor angle_[MAX_TARGETS]; +}; + +TEST_F(LD2460ReadlineTest, BackToBackFrames) { + this->ld2460_.feed(make_periodic_frame({{10, 10}})); + this->ld2460_.feed(make_periodic_frame({{10, 10}, {-20, 30}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->x_[1].state, -2.0f, 0.001f); + this->ld2460_.feed(make_periodic_frame({{12, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 1.2f, 0.001f); + EXPECT_TRUE(std::isnan(this->x_[1].state)); +} + +TEST_F(LD2460ReadlineTest, MixedFrameTypes) { + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_ack_frame(1)); + this->ld2460_.feed(make_periodic_frame({{-5, 40}, {5, 40}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->y_[1].state, 4.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, FooterBytesInsideGarbage) { + this->ld2460_.feed({0x01, 0x02, 0x03, 0x04, 0xF8, 0xF7, 0xF6, 0xF5}); + this->ld2460_.feed({0x10, 0x20, 0x30, 0x40, 0x04, 0x03, 0x02, 0x01}); + EXPECT_TRUE(std::isnan(this->count_.state)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); +} + +TEST_F(LD2460ReadlineTest, OverflowThenValidFrame) { + this->ld2460_.feed(std::vector(MAX_LINE_LENGTH + 8, 0x11)); + // The frame right after the overflow lands behind leftover bytes; the header resync skips them + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 1.5f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, MaxTargetsFrame) { + this->ld2460_.feed(make_periodic_frame({{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 5); + EXPECT_NEAR(this->x_[4].state, 0.9f, 0.001f); + EXPECT_NEAR(this->y_[4].state, 1.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, CoordinateDecoding) { + this->ld2460_.feed(make_periodic_frame({{15, 23}, {-300, -1}})); + EXPECT_NEAR(this->x_[0].state, 1.5f, 0.001f); + EXPECT_NEAR(this->y_[0].state, 2.3f, 0.001f); + EXPECT_NEAR(this->distance_[0].state, 2.746f, 0.001f); + EXPECT_NEAR(this->x_[1].state, -30.0f, 0.001f); + EXPECT_NEAR(this->y_[1].state, -0.1f, 0.001f); + + this->ld2460_.feed(make_periodic_frame({})); + EXPECT_FLOAT_EQ(this->count_.state, 0); + EXPECT_TRUE(std::isnan(this->x_[0].state)); + EXPECT_TRUE(std::isnan(this->distance_[0].state)); + EXPECT_TRUE(std::isnan(this->angle_[1].state)); +} + +TEST_F(LD2460ReadlineTest, AngleByInstallationMode) { + // Side mount (default): atan2(x, y) + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); + + // Top mount: atan2(y, x), wrapped to 0..360; the same coordinates are republished + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); + this->ld2460_.feed(make_periodic_frame({{15, -23}})); + EXPECT_NEAR(this->angle_[0].state, 303.11f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, AckHandling) { + // An ACK for another command or with a bad length leaves the side mount in place + this->ld2460_.feed({0xFD, 0xFC, 0xFB, 0xFA, 0x0B, 0x0C, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01}); + this->ld2460_.feed({0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0D, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01}); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); + + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, QueryResentWhileNoAck) { + // No ACK arrived: the first two data frames resend the 12-byte query (three attempts with the + // one from setup), after which the hub gives up and assumes a side mount + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, 12)).Times(2); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); + this->ld2460_.feed(make_periodic_frame({{30, 30}})); + this->ld2460_.feed(make_periodic_frame({{40, 40}})); +} + +TEST_F(LD2460ReadlineTest, InvalidModeKeepsQuerying) { + // A mode byte that is neither side nor top is rejected and the next frame asks again + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, 12)).Times(1); + this->ld2460_.feed(make_ack_frame(3)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, StrayBytesBeforeFrame) { + // Garbage in front of a frame is skipped once the footer is seen, for data and command frames + this->ld2460_.feed({0x01, 0x02, 0x03}); + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed({0xAA, 0xBB}); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, StrayByteThenTruncatedFrame) { + // The truncated frame's header is not followed by a matching length, so the full frame is used + this->ld2460_.feed({0x00, 0xF4, 0xF3, 0xF2, 0xF1, 0x04}); + this->ld2460_.feed(make_periodic_frame({{20, 30}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 2.0f, 0.001f); + EXPECT_NEAR(this->y_[0].state, 3.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, HeaderBytesInsidePayload) { + // The target bytes F4 F3 F2 F1 repeat the data header; the frame still parses at offset 0 + this->ld2460_.feed(make_periodic_frame({{static_cast(0xF3F4), static_cast(0xF1F2)}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, -308.4f, 0.01f); + EXPECT_NEAR(this->y_[0].state, -359.8f, 0.01f); +} + +TEST_F(LD2460ReadlineTest, PartialTargetRejected) { + // 13 bytes of payload is not a whole number of 4-byte targets + std::vector frame = {0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0D, 0x00, 0x0F, 0x00, 0xF8, 0xF7, 0xF6, 0xF5}; + this->ld2460_.feed(frame); + EXPECT_TRUE(std::isnan(this->count_.state)); +} + +TEST_F(LD2460ReadlineTest, NoQueryAfterAck) { + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, ::testing::_)).Times(0); + this->ld2460_.feed(make_ack_frame(1)); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); +} + +TEST_F(LD2460ReadlineTest, DataCallbackOncePerValidFrame) { + int calls = 0; + this->ld2460_.add_on_data_callback([&calls]() { calls++; }); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); + EXPECT_EQ(calls, 2); + // A frame with the data footer but a wrong header is rejected without a callback + this->ld2460_.feed({0x00, 0x00, 0x00, 0x00, 0x04, 0x0B, 0x00, 0xF8, 0xF7, 0xF6, 0xF5}); + this->ld2460_.feed(make_ack_frame(2)); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::ld2460::testing diff --git a/tests/components/ld2460/test.esp32-idf.yaml b/tests/components/ld2460/test.esp32-idf.yaml new file mode 100644 index 0000000000..e4efcf68fa --- /dev/null +++ b/tests/components/ld2460/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ld2460: !include common.yaml diff --git a/tests/components/ld2460/test.esp8266-ard.yaml b/tests/components/ld2460/test.esp8266-ard.yaml new file mode 100644 index 0000000000..d66d260423 --- /dev/null +++ b/tests/components/ld2460/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ld2460: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/common.yaml b/tests/components/lis2dw12_i2c/common.yaml new file mode 100644 index 0000000000..c0a58a00db --- /dev/null +++ b/tests/components/lis2dw12_i2c/common.yaml @@ -0,0 +1,60 @@ +lis2dw12_i2c: + id: lis2dw12_i2c_accel + i2c_id: i2c_bus + range: 4G + power_mode: high_performance + output_data_rate: 100Hz + filter_bandwidth: odr_div_2 + low_noise: true + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + - then: + - logger.log: "Tapped again" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_freefall: + - then: + - logger.log: "Freefall detected" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + orientation: Orientation + +binary_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + tap: Single tap + double_tap: + name: Double tap + freefall: + name: Freefall + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/lis2dw12_i2c/test.esp32-idf.yaml b/tests/components/lis2dw12_i2c/test.esp32-idf.yaml new file mode 100644 index 0000000000..e5af154192 --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml b/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml new file mode 100644 index 0000000000..77fd8e299b --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml b/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml new file mode 100644 index 0000000000..286ab79298 --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_spi/common.yaml b/tests/components/lis2dw12_spi/common.yaml new file mode 100644 index 0000000000..88f730a83d --- /dev/null +++ b/tests/components/lis2dw12_spi/common.yaml @@ -0,0 +1,61 @@ +lis2dw12_spi: + id: lis2dw12_spi_accel + spi_id: spi_bus + cs_pin: GPIO5 + range: 4G + power_mode: high_performance + output_data_rate: 100Hz + filter_bandwidth: odr_div_2 + low_noise: true + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + - then: + - logger.log: "Tapped again" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_freefall: + - then: + - logger.log: "Freefall detected" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + orientation: Orientation + +binary_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + tap: Single tap + double_tap: + name: Double tap + freefall: + name: Freefall + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/lis2dw12_spi/test.esp32-idf.yaml b/tests/components/lis2dw12_spi/test.esp32-idf.yaml new file mode 100644 index 0000000000..fcb5a6a526 --- /dev/null +++ b/tests/components/lis2dw12_spi/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis2dw12_spi/test.esp8266-ard.yaml b/tests/components/lis2dw12_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..d287bfe46a --- /dev/null +++ b/tests/components/lis2dw12_spi/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis2dw12_spi/test.rp2040-ard.yaml b/tests/components/lis2dw12_spi/test.rp2040-ard.yaml new file mode 100644 index 0000000000..6220326702 --- /dev/null +++ b/tests/components/lis2dw12_spi/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis3dh/common.yaml b/tests/components/lis3dh/common.yaml new file mode 100644 index 0000000000..54f492ecfa --- /dev/null +++ b/tests/components/lis3dh/common.yaml @@ -0,0 +1,67 @@ +motion: + - platform: lis3dh + id: lis3dh_main + i2c_id: i2c_bus + accelerometer_range: 2G + accelerometer_odr: 100HZ + operating_mode: HIGH_RESOLUTION + axis_map: + x: y + y: x + z: -z + # Motion (activity) interrupt - keeps the INT pad asserted while moving so it + # can be used as the deep_sleep wakeup_pin. + interrupt: + pin: INT1 + threshold: 1.1g + duration: 0ms + axes: [x, y, z] + latched: true + active_high: true + high_pass_filter: false + + # A second device covering the other side of each option: low power mode with + # the rate that only exists there, routed to INT2, active low, unlatched, and + # with the high-pass filter on. + - platform: lis3dh + id: lis3dh_low_power + i2c_id: i2c_bus + address: 0x19 + accelerometer_range: 16G + accelerometer_odr: 1620HZ + operating_mode: LOW_POWER + interrupt: + pin: INT2 + threshold: 0.05g + duration: 1ms + axes: [z] + latched: false + active_high: false + high_pass_filter: true + +sensor: + - platform: lis3dh + lis3dh_id: lis3dh_main + type: temperature + name: "LIS3DH Temperature" + + - platform: motion + motion_id: lis3dh_main + type: acceleration_x + name: "Accel X" + - platform: motion + motion_id: lis3dh_main + type: acceleration_y + name: "Accel Y" + - platform: motion + motion_id: lis3dh_main + type: acceleration_z + name: "Accel Z" + - platform: motion + motion_id: lis3dh_main + type: pitch + name: "Pitch" + - platform: motion + motion_id: lis3dh_main + type: roll + name: "Roll" diff --git a/tests/components/lis3dh/test.esp32-idf.yaml b/tests/components/lis3dh/test.esp32-idf.yaml new file mode 100644 index 0000000000..41a8a05165 --- /dev/null +++ b/tests/components/lis3dh/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis3dh: !include common.yaml diff --git a/tests/components/lis3dh/test.esp8266-ard.yaml b/tests/components/lis3dh/test.esp8266-ard.yaml new file mode 100644 index 0000000000..63c7f2a94a --- /dev/null +++ b/tests/components/lis3dh/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis3dh: !include common.yaml diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml new file mode 100644 index 0000000000..c1608f1a3c --- /dev/null +++ b/tests/components/lp5562/common.yaml @@ -0,0 +1,21 @@ +lp5562: + - address: 0x30 + id: lp5562_1 + +output: + - platform: lp5562 + id: lp5562_red + channel: red + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_green + channel: green + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_blue + channel: blue + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_white + channel: white + lp5562_id: lp5562_1 diff --git a/tests/components/lp5562/test.esp32-idf.yaml b/tests/components/lp5562/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/lp5562/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.esp8266-ard.yaml b/tests/components/lp5562/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/lp5562/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.rp2040-ard.yaml b/tests/components/lp5562/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/lp5562/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 681eb41b5b..952dd5f50f 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,58 @@ lvgl: - label: text: "2,2" + # Sized from its text font, with a colour given as an id + - color_picker: + id: lv_color_picker_1 + width: SIZE_CONTENT + text_font: space16 + color: light_blue + on_value: + - logger.log: + format: "color picker is now %d,%d,%d" + args: [x.r, x.g, x.b] + on_release: + - lvgl.color_picker.update: + id: lv_color_picker_2 + color: !lambda return x; + + # Fixed size, with a colour given as a hex literal + - color_picker: + id: lv_color_picker_2 + width: 200 + color: 0x3366CC + # Styles for the sliders the widget is built from + items: + border_color: 0x808080 + pressed: + border_width: 2 + knob: + radius: 4 + # A named style, added for both a part on its own and a part with a state + styles: [style_test] + pressed: + styles: [style_test] + on_update: + - logger.log: color picker updated + + # Only the red, green and blue sliders + - color_picker: + id: lv_color_picker_3 + width: 150 + sliders: rgb + + # A hue and saturation pair, as used where brightness is set elsewhere + - color_picker: + id: lv_color_picker_4 + width: 150 + sliders: [hue, saturation] + # Widens the area each slider can be pressed on, so the knobs are easier to move + ext_click_area: 10 + # Applies to the widget as a whole, unlike the setting above + scrollbar_mode: "OFF" + knob: + bg_color: 0xFFFFFF + font: - file: "gfonts://Roboto" id: space16 diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 1df27d82f7..3c4825b067 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -79,6 +79,11 @@ lvgl: - lvgl.animation.start: id: anim_slide lvgl_id: lvgl_0 + # Sliders only and no complex gradients, so the wheel's gradient set-up must still build + - color_picker: + id: host_hue_picker + width: 200 + sliders: hue font: - file: "gfonts://Roboto" diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml new file mode 100644 index 0000000000..de0247ed4e --- /dev/null +++ b/tests/components/max17048/common.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: max17048 + battery_voltage: + name: Battery Voltage + battery_level: + name: Battery Level + rate: + name: Battery Rate diff --git a/tests/components/max17048/test.esp32-idf.yaml b/tests/components/max17048/test.esp32-idf.yaml new file mode 100644 index 0000000000..e9930c953d --- /dev/null +++ b/tests/components/max17048/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.esp8266-ard.yaml b/tests/components/max17048/test.esp8266-ard.yaml new file mode 100644 index 0000000000..95f0f76f14 --- /dev/null +++ b/tests/components/max17048/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.rp2040-ard.yaml b/tests/components/max17048/test.rp2040-ard.yaml new file mode 100644 index 0000000000..7f24480cdc --- /dev/null +++ b/tests/components/max17048/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + max17048: !include common.yaml diff --git a/tests/components/max31888/common.yaml b/tests/components/max31888/common.yaml new file mode 100644 index 0000000000..7b9ada2e82 --- /dev/null +++ b/tests/components/max31888/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: max31888 + one_wire_id: one_wire_bus + address: 0x1C0000031EDD2A54 + name: MAX31888 Temperature 1 + - platform: max31888 + one_wire_id: one_wire_bus + name: MAX31888 Temperature 2 + index: 1 diff --git a/tests/components/max31888/test.esp32-idf.yaml b/tests/components/max31888/test.esp32-idf.yaml new file mode 100644 index 0000000000..943a8c54a9 --- /dev/null +++ b/tests/components/max31888/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp32-idf.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.esp8266-ard.yaml b/tests/components/max31888/test.esp8266-ard.yaml new file mode 100644 index 0000000000..29ed6be26a --- /dev/null +++ b/tests/components/max31888/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp8266-ard.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.rp2040-ard.yaml b/tests/components/max31888/test.rp2040-ard.yaml new file mode 100644 index 0000000000..80de52bae2 --- /dev/null +++ b/tests/components/max31888/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/rp2040-ard.yaml + max31888: !include common.yaml diff --git a/tests/components/mistral_ir/__init__.py b/tests/components/mistral_ir/__init__.py new file mode 100644 index 0000000000..c74d97e1d1 --- /dev/null +++ b/tests/components/mistral_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The platform's to_code never runs here; without this request remote_base drops aeha_protocol.cpp + remote_base.request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/mistral_ir/benchmark.yaml b/tests/components/mistral_ir/benchmark.yaml new file mode 100644 index 0000000000..ef3f9fb05d --- /dev/null +++ b/tests/components/mistral_ir/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The mistral_ir, climate_ir and remote_transmitter sources come in through +# the dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/mistral_ir/common.yaml b/tests/components/mistral_ir/common.yaml new file mode 100644 index 0000000000..43a2225bcb --- /dev/null +++ b/tests/components/mistral_ir/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: mistral_ir + name: Mistral IR + transmitter_id: xmitr diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp new file mode 100644 index 0000000000..37db86d10a --- /dev/null +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -0,0 +1,330 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/mistral_ir/mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +namespace esphome::mistral_ir::testing { + +using Frame = std::vector; + +static constexpr uint8_t POWER_OFF = 0x00; +static constexpr uint8_t POWER_ON = 0x20; +static constexpr uint8_t MODE_COOL = 0xC0; +static constexpr uint8_t MODE_DRY = 0x40; +static constexpr uint8_t MODE_FAN_ONLY = 0x80; +static constexpr uint8_t MODE_HEAT = 0x60; +static constexpr uint8_t MODE_AUTO = 0x10; +static constexpr uint8_t FAN_AUTO_BYTE1 = 0xA4; +static constexpr uint8_t FAN_AUTO_BYTE6 = 0x00; +static constexpr uint8_t FAN_LOW_BYTE1 = 0x44; +static constexpr uint8_t FAN_LOW_BYTE6 = 0x40; +static constexpr uint8_t FAN_MEDIUM_BYTE1 = 0x24; +static constexpr uint8_t FAN_MEDIUM_BYTE6 = 0xC0; +static constexpr uint8_t FAN_HIGH_BYTE1 = 0xA4; +static constexpr uint8_t FAN_HIGH_BYTE6 = 0xA0; +static constexpr uint16_t WRONG_ADDRESS = 0x322D; +static constexpr uint32_t TOLERANCE_PERCENT = 25; + +static constexpr climate::ClimateMode ALL_MODES[] = { + climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, +}; +static constexpr climate::ClimateFanMode ALL_FANS[] = { + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, +}; + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +static uint8_t checksum(const Frame &frame) { + uint8_t sum = 0; + for (size_t idx : {1, 3, 4, 5, 6, 8}) + sum += reverse_bits(frame[idx]); + return reverse_bits(sum); +} + +/// A received frame with a valid checksum, which callers may overwrite +static Frame make_frame(uint8_t byte1, uint8_t power, uint8_t mode, uint8_t temperature, uint8_t byte6) { + Frame frame{0x56, byte1, 0x08, power, mode, reverse_bits(static_cast(0x9F - temperature)), + byte6, 0x00, 0xE8, 0x00, 0x60, 0x00}; + frame[11] = checksum(frame); + return frame; +} + +static remote_base::RawTimings encode(uint16_t address, const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::AEHAProtocol().encode(&data, address, frame.data(), frame.size()); + return data.get_data(); +} + +struct Fixture { + CapturingTransmitter transmitter; + MistralIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_mistral_ir"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.perform(); + } + + /// The 12 data bytes of the last transmission, decoded by the AEHA protocol + Frame last_frame() const { + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return {}; + remote_base::RemoteReceiveData data(this->transmitter.frames.back(), TOLERANCE_PERCENT, + remote_base::TOLERANCE_MODE_PERCENTAGE); + const auto aeha = remote_base::AEHAProtocol().decode(data); + EXPECT_TRUE(aeha.has_value()); + if (!aeha.has_value()) + return {}; + EXPECT_EQ(aeha->address, MISTRAL_ADDRESS); + EXPECT_EQ(aeha->data.size(), MISTRAL_FRAME_SIZE); + return aeha->data; + } + + bool receive(const remote_base::RawTimings &raw) { + remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + + bool receive(const Frame &frame, uint16_t address = MISTRAL_ADDRESS) { return this->receive(encode(address, frame)); } + + void expect_state(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) const { + EXPECT_EQ(this->climate.mode, mode); + EXPECT_FLOAT_EQ(this->climate.target_temperature, temperature); + EXPECT_EQ(this->climate.fan_mode, fan); + } +}; + +TEST(MistralIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + uint8_t checksum; + }; + // 22 C: 0x9F - 22 = 0x89, reversed 0x91. Checksum sums the reversed bytes 1, 3, 4, 5, 6 and 8: + // fan AUTO 0xA4 -> 0x25, power on 0x20 -> 0x04, mode, temperature 0x89, byte 6 0x00, 0xE8 -> 0x17 + const Case cases[] = { + // COOL 0xC0 -> 0x03: 0x25 + 0x04 + 0x03 + 0x89 + 0x00 + 0x17 = 0xCC, reversed 0x33 + {climate::CLIMATE_MODE_COOL, MODE_COOL, 0x33}, + // DRY 0x40 -> 0x02: sum 0xCB, reversed 0xD3 + {climate::CLIMATE_MODE_DRY, MODE_DRY, 0xD3}, + // FAN_ONLY 0x80 -> 0x01: sum 0xCA, reversed 0x53 + {climate::CLIMATE_MODE_FAN_ONLY, MODE_FAN_ONLY, 0x53}, + // HEAT 0x60 -> 0x06: sum 0xCF, reversed 0xF3 + {climate::CLIMATE_MODE_HEAT, MODE_HEAT, 0xF3}, + // HEAT_COOL 0x10 -> 0x08: sum 0xD1, reversed 0x8B + {climate::CLIMATE_MODE_HEAT_COOL, MODE_AUTO, 0x8B}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.mode)); + f.command(c.mode, 22.0f, climate::CLIMATE_FAN_AUTO); + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_ON, c.code, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, c.checksum}; + EXPECT_EQ(f.last_frame(), expected); + } +} + +TEST(MistralIR, EncodesEachFanMode) { + struct Case { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; + uint8_t checksum; + }; + // COOL at 24 C: 0x9F - 24 = 0x87, reversed 0xE1. Fixed part of the checksum sum: + // power on 0x04 + COOL 0x03 + temperature 0x87 + 0xE8 -> 0x17 = 0xA5, plus reversed bytes 1 and 6 + const Case cases[] = { + // AUTO 0xA4 -> 0x25, 0x00 -> 0x00: 0xA5 + 0x25 + 0x00 = 0xCA, reversed 0x53 + {climate::CLIMATE_FAN_AUTO, FAN_AUTO_BYTE1, FAN_AUTO_BYTE6, 0x53}, + // LOW 0x44 -> 0x22, 0x40 -> 0x02: 0xA5 + 0x22 + 0x02 = 0xC9, reversed 0x93 + {climate::CLIMATE_FAN_LOW, FAN_LOW_BYTE1, FAN_LOW_BYTE6, 0x93}, + // MEDIUM 0x24 -> 0x24, 0xC0 -> 0x03: 0xA5 + 0x24 + 0x03 = 0xCC, reversed 0x33 + {climate::CLIMATE_FAN_MEDIUM, FAN_MEDIUM_BYTE1, FAN_MEDIUM_BYTE6, 0x33}, + // HIGH 0xA4 -> 0x25, 0xA0 -> 0x05: 0xA5 + 0x25 + 0x05 = 0xCF, reversed 0xF3 + {climate::CLIMATE_FAN_HIGH, FAN_HIGH_BYTE1, FAN_HIGH_BYTE6, 0xF3}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.fan)); + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[1], c.byte1); + EXPECT_EQ(frame[4], MODE_COOL); + EXPECT_EQ(frame[5], 0xE1); + EXPECT_EQ(frame[6], c.byte6); + EXPECT_EQ(frame[11], c.checksum); + } +} + +TEST(MistralIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_AUTO); + // OFF has no mode code, so byte 4 falls back to auto 0x10 -> 0x08 with power 0x00: + // 0x25 + 0x00 + 0x08 + 0x89 + 0x00 + 0x17 = 0xCD, reversed 0xB3 + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_OFF, MODE_AUTO, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, 0xB3}; + EXPECT_EQ(f.last_frame(), expected); +} + +TEST(MistralIR, EncodesTemperatureRoundedAndClamped) { + struct Case { + float requested; + uint8_t byte5; + }; + const Case cases[] = { + {16.0f, 0xF1}, // 0x9F - 16 = 0x8F, reversed 0xF1 + {22.0f, 0x91}, // 0x9F - 22 = 0x89, reversed 0x91 + {30.0f, 0x81}, // 0x9F - 30 = 0x81, reversed 0x81 + {15.6f, 0xF1}, // rounds to 16 + {30.4f, 0x81}, // rounds to 30 + {10.0f, 0xF1}, // clamps to 16 + {35.0f, 0x81}, // clamps to 30 + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(c.requested); + f.command(climate::CLIMATE_MODE_COOL, c.requested, climate::CLIMATE_FAN_AUTO); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[5], c.byte5); + EXPECT_EQ(frame[11], checksum(frame)); + } +} + +TEST(MistralIR, RoundTripsEveryModeAndFan) { + for (climate::ClimateMode mode : ALL_MODES) { + for (climate::ClimateFanMode fan : ALL_FANS) { + for (float temperature : {16.0f, 23.0f, 30.0f}) { + SCOPED_TRACE(static_cast(mode) * 1000 + static_cast(fan) * 100 + static_cast(temperature)); + Fixture sender; + sender.command(mode, temperature, fan); + EXPECT_EQ(sender.last_frame().size(), MISTRAL_FRAME_SIZE); + Fixture receiver; + EXPECT_TRUE(receiver.receive(sender.transmitter.frames.back())); + receiver.expect_state(mode, temperature, fan); + } + } + } +} + +TEST(MistralIR, ReceivesHeatFrame) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_HEAT, 25.0f, climate::CLIMATE_FAN_LOW); +} + +TEST(MistralIR, ReceivedPowerOffKeepsTemperatureAndFan) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_AUTO_BYTE1, POWER_OFF, MODE_AUTO, 28, FAN_AUTO_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsBadChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[11] ^= 0x01; + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsChangedFixedByte) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (size_t idx : {0, 2, 7, 8, 9, 10}) { + SCOPED_TRACE(idx); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[idx] ^= 0x01; + // Byte 8 is part of the checksum, so keep it valid to test the fixed-byte check alone + frame[11] = checksum(frame); + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsUnknownPowerModeAndFan) { + struct Case { + const char *name; + uint8_t byte1; + uint8_t power; + uint8_t mode; + uint8_t byte6; + }; + const Case cases[] = { + {"power 0x10", FAN_LOW_BYTE1, 0x10, MODE_HEAT, FAN_LOW_BYTE6}, + {"mode 0x20", FAN_LOW_BYTE1, POWER_ON, 0x20, FAN_LOW_BYTE6}, + {"fan 0x44/0x00", FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, FAN_AUTO_BYTE6}, + {"fan 0x00/0x40", 0x00, POWER_ON, MODE_HEAT, FAN_LOW_BYTE6}, + }; + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (const Case &c : cases) { + SCOPED_TRACE(c.name); + EXPECT_FALSE(f.receive(make_frame(c.byte1, c.power, c.mode, 25, c.byte6))); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsWrongAddress) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + EXPECT_FALSE(f.receive(frame, WRONG_ADDRESS)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsWrongFrameLength) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + Frame shorter(frame.begin(), frame.begin() + 11); + EXPECT_FALSE(f.receive(shorter)); + Frame longer = frame; + longer.push_back(0x00); + EXPECT_FALSE(f.receive(longer)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +} // namespace esphome::mistral_ir::testing diff --git a/tests/components/mistral_ir/test.esp32-idf.yaml b/tests/components/mistral_ir/test.esp32-idf.yaml new file mode 100644 index 0000000000..4f0186d9eb --- /dev/null +++ b/tests/components/mistral_ir/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + mistral_ir: !include common.yaml diff --git a/tests/components/mistral_ir/test.esp8266-ard.yaml b/tests/components/mistral_ir/test.esp8266-ard.yaml new file mode 100644 index 0000000000..3dddb49266 --- /dev/null +++ b/tests/components/mistral_ir/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + mistral_ir: !include common.yaml diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 4f4347d89a..fc2c833e83 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -287,13 +287,20 @@ display: then: logger.log: "Nextion reported a buffer overflow!" on_custom_text_sensor: - then: - - lambda: |- - // key: StringRef, value: StringRef - if (key == "csv") { - // parse value here, or forward to your own component - ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); - } + - then: + - lambda: |- + // key: StringRef, value: StringRef + if (key == "csv") { + // parse value here, or forward to your own component + ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); + } + # Deferring: key and value are passed as std::string + - then: + - delay: 1s + - lambda: |- + if (key == "csv") { + ESP_LOGD("nextion.csv", "Got CSV earlier: %s", value.c_str()); + } on_custom_sensor: then: - lambda: |- @@ -308,9 +315,13 @@ display: ESP_LOGD("nextion.btn", "btn1=%s", ONOFF(value)); } on_custom_switch: - then: - - lambda: |- - ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); + - then: + - lambda: |- + ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); + - then: + - delay: 1s + - lambda: |- + ESP_LOGD("nextion.sw", "%s was %s", key.c_str(), ONOFF(value)); on_page: then: lambda: 'ESP_LOGD("display","Display shows new page %u", x);' diff --git a/tests/components/ns2009/common.yaml b/tests/components/ns2009/common.yaml new file mode 100644 index 0000000000..f2472d2d5d --- /dev/null +++ b/tests/components/ns2009/common.yaml @@ -0,0 +1,27 @@ +display: + - platform: mipi_spi + model: ILI9341 + id: ns_display + dc_pin: ${dc_pin} + cs_pin: ${cs_pin} + color_depth: 8 + invert_colors: false + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: ns2009 + id: ns_touchscreen + i2c_id: i2c_bus + display: ns_display + transform: + mirror_y: true + calibration: + x_min: 400 + x_max: 3700 + y_min: 300 + y_max: 3700 + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] diff --git a/tests/components/ns2009/test.esp32-idf.yaml b/tests/components/ns2009/test.esp32-idf.yaml new file mode 100644 index 0000000000..80b28696a1 --- /dev/null +++ b/tests/components/ns2009/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO15 + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.esp8266-ard.yaml b/tests/components/ns2009/test.esp8266-ard.yaml new file mode 100644 index 0000000000..ca78f2b029 --- /dev/null +++ b/tests/components/ns2009/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.rp2040-ard.yaml b/tests/components/ns2009/test.rp2040-ard.yaml new file mode 100644 index 0000000000..c10519f9d0 --- /dev/null +++ b/tests/components/ns2009/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + ns2009: !include common.yaml diff --git a/tests/components/ota/old_key.yaml b/tests/components/ota/old_key.yaml new file mode 100644 index 0000000000..8074ca06f6 --- /dev/null +++ b/tests/components/ota/old_key.yaml @@ -0,0 +1,12 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + encryption: + old_key: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" diff --git a/tests/components/ota/test_update_lock.cpp b/tests/components/ota/test_update_lock.cpp new file mode 100644 index 0000000000..28314cb1c2 --- /dev/null +++ b/tests/components/ota/test_update_lock.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/ota/ota_backend_factory.h" + +namespace esphome::ota::testing { + +TEST(UpdateLock, SecondHolderIsRefused) { + UpdateLock first; + UpdateLock second; + EXPECT_TRUE(first.try_acquire()); + EXPECT_FALSE(second.try_acquire()); + first.release(); + EXPECT_TRUE(second.try_acquire()); +} + +TEST(UpdateLock, AcquireIsIdempotentForTheHolder) { + UpdateLock lock; + EXPECT_TRUE(lock.try_acquire()); + EXPECT_TRUE(lock.try_acquire()); + lock.release(); + UpdateLock other; + EXPECT_TRUE(other.try_acquire()); +} + +TEST(UpdateLock, ReleaseByNonHolderKeepsLock) { + UpdateLock holder; + UpdateLock other; + EXPECT_TRUE(holder.try_acquire()); + other.release(); + EXPECT_FALSE(other.try_acquire()); +} + +TEST(UpdateLock, DestructorReleases) { + { + UpdateLock scoped; + EXPECT_TRUE(scoped.try_acquire()); + } + UpdateLock next; + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, IsHeldTracksOwnership) { + UpdateLock holder; + UpdateLock other; + EXPECT_FALSE(holder.is_held()); + EXPECT_TRUE(holder.try_acquire()); + EXPECT_TRUE(holder.is_held()); + EXPECT_FALSE(other.try_acquire()); + EXPECT_FALSE(other.is_held()); +} + +class TestUpdateLock : public UpdateLock { + public: + using UpdateLock::force_release; +}; + +TEST(UpdateLock, HoldUntilRebootOutlivesHolder) { + { + UpdateLock committed; + EXPECT_TRUE(committed.try_acquire()); + committed.hold_until_reboot(); + EXPECT_FALSE(committed.is_held()); + } + UpdateLock next; + EXPECT_FALSE(next.try_acquire()); + TestUpdateLock::force_release(); + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, SecondBackendBeginIsBusy) { + auto first = make_ota_backend(); + auto second = make_ota_backend(); + ASSERT_EQ(first->begin(1024), OTA_RESPONSE_OK); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_ERROR_UPDATE_BUSY); + // Native OTA calls abort() after a refused begin(). + second->abort(); + uint8_t data[16] = {}; + EXPECT_EQ(first->write(data, sizeof(data)), OTA_RESPONSE_OK); + first->abort(); + first.reset(); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_OK); + second->abort(); +} + +} // namespace esphome::ota::testing diff --git a/tests/components/ota/validate-old_key.esp8266-ard.yaml b/tests/components/ota/validate-old_key.esp8266-ard.yaml new file mode 100644 index 0000000000..f58187f455 --- /dev/null +++ b/tests/components/ota/validate-old_key.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include old_key.yaml diff --git a/tests/components/pd_pioneer_ir/__init__.py b/tests/components/pd_pioneer_ir/__init__.py new file mode 100644 index 0000000000..b2d1d1c716 --- /dev/null +++ b/tests/components/pd_pioneer_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The platform's to_code never runs here; without this request remote_base drops the protocol source + remote_base.request_protocol("pd_pioneer") + + manifest.to_code = to_code_testing diff --git a/tests/components/pd_pioneer_ir/benchmark.yaml b/tests/components/pd_pioneer_ir/benchmark.yaml new file mode 100644 index 0000000000..5126d84934 --- /dev/null +++ b/tests/components/pd_pioneer_ir/benchmark.yaml @@ -0,0 +1,2 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test build +climate: [] diff --git a/tests/components/pd_pioneer_ir/common.yaml b/tests/components/pd_pioneer_ir/common.yaml new file mode 100644 index 0000000000..e799f7587f --- /dev/null +++ b/tests/components/pd_pioneer_ir/common.yaml @@ -0,0 +1,6 @@ +climate: + - platform: pd_pioneer_ir + name: Pioneer AC + transmitter_id: xmitr + receiver_id: rcvr + use_fahrenheit: true diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp new file mode 100644 index 0000000000..e7a5e2c18f --- /dev/null +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -0,0 +1,372 @@ +#include +#include +#include +#include + +#include "esphome/components/pd_pioneer_ir/pd_pioneer_ir.h" +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::pd_pioneer_ir::testing { + +using remote_base::PDPioneerData; +using Frame = std::vector; + +static constexpr uint32_t TOLERANCE_PERCENT = 25; +static constexpr uint8_t FRAME_SIZE = PDPioneerData::FRAME_SIZE; + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +/// Fill in the checksum: the sum of the first 13 values, plus 15 for an odd burst +static Frame finalized(Frame frame) { + uint8_t sum = frame[3] == 0x02 ? 15 : 0; + for (size_t idx = 0; idx < FRAME_SIZE - 1; idx++) + sum += frame[idx]; + frame[FRAME_SIZE - 1] = sum; + return frame; +} + +static Frame odd_frame(uint8_t fan5, uint8_t fan6, uint8_t swing) { + return finalized({0x23, 0xCB, 0x26, 0x02, 0x00, fan5, fan6, swing, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00}); +} + +static Frame even_frame(uint8_t power, uint8_t mode, uint8_t temp, uint8_t fan8, uint8_t byte12) { + return finalized({0x23, 0xCB, 0x26, 0x01, 0x00, power, mode, temp, fan8, 0x00, 0x00, 0x00, byte12, 0x00}); +} + +static remote_base::RawTimings encode(const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::PDPioneerProtocol().encode(&data, PDPioneerData(frame)); + return data.get_data(); +} + +/// RemoteReceiveData keeps a reference to the timings, so they must outlive it +static remote_base::RemoteReceiveData rx(const remote_base::RawTimings &raw) { + return remote_base::RemoteReceiveData(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); +} + +static Frame decode(const remote_base::RawTimings &raw) { + const auto decoded = remote_base::PDPioneerProtocol().decode(rx(raw)); + EXPECT_TRUE(decoded.has_value()); + if (!decoded.has_value()) + return {}; + return Frame(decoded->data(), decoded->data() + FRAME_SIZE); +} + +struct Fixture { + CapturingTransmitter transmitter; + PDPioneerIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_pd_pioneer_ir"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing = climate::CLIMATE_SWING_OFF, + climate::ClimatePreset preset = climate::CLIMATE_PRESET_NONE) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.set_preset(preset); + call.perform(); + } + + /// The odd and even bursts of the last command, decoded by the protocol + std::pair last_pair() const { + EXPECT_GE(this->transmitter.frames.size(), 2u); + if (this->transmitter.frames.size() < 2) + return {}; + const size_t n = this->transmitter.frames.size(); + return {decode(this->transmitter.frames[n - 2]), decode(this->transmitter.frames[n - 1])}; + } + + bool receive(const remote_base::RawTimings &raw) { + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(rx(raw)); + } + + bool receive(const Frame &frame) { return this->receive(encode(frame)); } +}; + +TEST(PDPioneerIR, ProtocolRoundTripsAFrame) { + const Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + EXPECT_EQ(decode(encode(frame)), frame); + const Frame odd = odd_frame(0x40, 0xC0, 0x00); + EXPECT_EQ(decode(encode(odd)), odd); +} + +TEST(PDPioneerIR, ProtocolRejectsBadChecksumAndUnknownBurst) { + Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + frame[13] ^= 0x01; + const remote_base::RawTimings bad = encode(frame); + EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(bad)).has_value()); + Frame unknown = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + unknown[3] = 0x03; + unknown = finalized(unknown); + const remote_base::RawTimings other = encode(unknown); + EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(other)).has_value()); +} + +TEST(PDPioneerIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + }; + // 24 C: 31.75 - 24 = 7.75, position 7 = 7, bit 2 of position 12 clear + static constexpr Case CASES[] = { + {climate::CLIMATE_MODE_COOL, 0x03}, + {climate::CLIMATE_MODE_HEAT, 0x01}, + {climate::CLIMATE_MODE_DRY, 0x02}, + {climate::CLIMATE_MODE_HEAT_COOL, 0x08}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.mode)); + Fixture f; + f.command(c.mode, 24.0f, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00)); + EXPECT_EQ(even, even_frame(0x24, c.code, 0x07, 0x05, 0x80)); + } +} + +TEST(PDPioneerIR, FanOnlyCarriesNoTemperature) { + Fixture f; + f.command(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x60, 0x40, 0x00)); + // Position 7 keeps the template value 0x0B and position 8 follows the fan + EXPECT_EQ(even, even_frame(0x24, 0x07, 0x0B, 0x02, 0x80)); +} + +TEST(PDPioneerIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00)); + EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80)); +} + +TEST(PDPioneerIR, EncodesFanModes) { + struct Case { + climate::ClimateFanMode fan; + uint8_t odd5; + uint8_t odd6; + uint8_t even8; + }; + static constexpr Case CASES[] = { + {climate::CLIMATE_FAN_AUTO, 0x40, 0x20, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x60, 0x40, 0x02}, + {climate::CLIMATE_FAN_MEDIUM, 0x40, 0x60, 0x03}, + {climate::CLIMATE_FAN_HIGH, 0x40, 0xC0, 0x05}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.fan)); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(c.odd5, c.odd6, 0x00)); + EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80)); + } +} + +TEST(PDPioneerIR, EncodesSwing) { + struct Case { + climate::ClimateSwingMode swing; + uint8_t odd7; + uint8_t even8; + }; + // Vertical swing also ORs 0x38 into the fan field of the even burst (0x05 for high) + static constexpr Case CASES[] = { + {climate::CLIMATE_SWING_VERTICAL, 0x08, 0x3D}, + {climate::CLIMATE_SWING_HORIZONTAL, 0x90, 0x05}, + {climate::CLIMATE_SWING_BOTH, 0x98, 0x3D}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.swing)); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, c.swing); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, c.odd7)); + EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80)); + } +} + +TEST(PDPioneerIR, SwingAndFanEncodeInAnyOrder) { + ControlData fan_first; + fan_first.set_fan_mode(climate::CLIMATE_FAN_LOW); + fan_first.set_swing_mode(climate::CLIMATE_SWING_BOTH); + fan_first.finalize(); + ControlData swing_first; + swing_first.set_swing_mode(climate::CLIMATE_SWING_BOTH); + swing_first.set_fan_mode(climate::CLIMATE_FAN_LOW); + swing_first.finalize(); + EXPECT_EQ(swing_first.odd(), fan_first.odd()); + EXPECT_EQ(swing_first.even(), fan_first.even()); + EXPECT_EQ(swing_first.odd()[7], 0x98); + EXPECT_EQ(swing_first.even()[8], 0x3A); +} + +TEST(PDPioneerIR, EncodesTemperatureInHalfDegrees) { + struct Case { + float temperature; + uint8_t byte7; + uint8_t byte12; + }; + // 31 - C in whole degrees, plus a bit for the half: 16 -> 15, 24.5 -> 7 with the bit set, 31 -> 0 + static constexpr Case CASES[] = { + {16.0f, 0x0F, 0x80}, {24.0f, 0x07, 0x80}, {24.5f, 0x07, 0x84}, + {31.0f, 0x00, 0x80}, {10.0f, 0x0F, 0x80}, {35.0f, 0x00, 0x80}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(c.temperature); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, c.temperature, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0x24, 0x03, c.byte7, 0x05, c.byte12)); + } +} + +TEST(PDPioneerIR, EncodesEcoPreset) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF, + climate::CLIMATE_PRESET_ECO); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0x25, 0x03, 0x07, 0x05, 0x80)); +} + +TEST(PDPioneerIR, OffWithEcoPresetStaysOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF, + climate::CLIMATE_PRESET_ECO); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80)); +} + +TEST(PDPioneerIR, ReceivesAnOddAndEvenPair) { + Fixture f; + // The odd burst alone changes nothing; the even burst completes the command + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_TRUE(f.receive(even_frame(0x25, 0x01, 0x07, 0x3A, 0x84))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.5f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); +} + +TEST(PDPioneerIR, ReceivesAnEvenBurstWithoutItsOddBurst) { + Fixture f; + // Position 8 is the medium fan code 0x03 with the vertical swing mask 0x38 + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3B, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // Horizontal swing from an earlier pair does not carry over to an even burst whose odd burst was missed + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3A, 0x80))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x00, 0x80))); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(PDPioneerIR, ReceivedUnknownModeIsIgnored) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + // Mode byte 0x05 belongs to no mode of this remote + EXPECT_FALSE(f.receive(even_frame(0x24, 0x05, 0x07, 0x05, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); +} + +TEST(PDPioneerIR, ReceivedTemperatureIsClamped) { + Fixture f; + // Position 7 above 0x0F would decode below the 16 C minimum + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x1F, 0x05, 0x80))); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 16.0f); +} + +TEST(PDPioneerIR, ReceivedAutoModeIsIgnoredWithoutHeatCool) { + Fixture f; + f.climate.set_supports_heat_cool(false); + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x08))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x08, 0x07, 0x3A, 0x80))); + // The mode stays as it was; the rest of the command still applies + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.0f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PDPioneerIR, ReceivedOffAndFanOnlyKeepTheTemperature) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00))); + EXPECT_TRUE(f.receive(even_frame(0xA0, 0x01, 0x0C, 0x03, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); + EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x07, 0x0B, 0x05, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); +} + +TEST(PDPioneerIR, TransmittedCommandIsReceivedBack) { + Fixture sender; + sender.command(climate::CLIMATE_MODE_DRY, 19.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_VERTICAL); + Fixture receiver; + const size_t n = sender.transmitter.frames.size(); + EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2])); + EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1])); + EXPECT_EQ(receiver.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(receiver.climate.target_temperature, 19.0f); + EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(receiver.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PDPioneerIR, AutoFanIsReceivedBack) { + Fixture sender; + sender.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO); + Fixture receiver; + const size_t n = sender.transmitter.frames.size(); + EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2])); + EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1])); + EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(PDPioneerIR, StaticCodeIsSentWithItsChecksum) { + // A 13-byte code, filled and finalized as the transmit action does + static const uint8_t CODE[] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x07, 0x05, 0x00, 0x00, 0x00, 0x80}; + remote_base::PDPioneerData data(CODE, sizeof(CODE)); + data.finalize(); + remote_base::RemoteTransmitData dst; + remote_base::PDPioneerProtocol().encode(&dst, data); + EXPECT_EQ(decode(dst.get_data()), even_frame(0x24, 0x03, 0x07, 0x05, 0x80)); +} + +} // namespace esphome::pd_pioneer_ir::testing diff --git a/tests/components/pd_pioneer_ir/test.esp32-idf.yaml b/tests/components/pd_pioneer_ir/test.esp32-idf.yaml new file mode 100644 index 0000000000..6172163ce4 --- /dev/null +++ b/tests/components/pd_pioneer_ir/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + pd_pioneer_ir: !include common.yaml diff --git a/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml b/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml new file mode 100644 index 0000000000..98fbf9714a --- /dev/null +++ b/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + pd_pioneer_ir: !include common.yaml diff --git a/tests/components/remko_ar715/__init__.py b/tests/components/remko_ar715/__init__.py new file mode 100644 index 0000000000..ec1dafdf15 --- /dev/null +++ b/tests/components/remko_ar715/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/remko_ar715/common.yaml b/tests/components/remko_ar715/common.yaml new file mode 100644 index 0000000000..b284bc20c2 --- /dev/null +++ b/tests/components/remko_ar715/common.yaml @@ -0,0 +1,3 @@ +climate: + - platform: remko_ar715 + name: Test RemkoAr715 diff --git a/tests/components/remko_ar715/remko_ar715_tests.cpp b/tests/components/remko_ar715/remko_ar715_tests.cpp new file mode 100644 index 0000000000..bb1cece60f --- /dev/null +++ b/tests/components/remko_ar715/remko_ar715_tests.cpp @@ -0,0 +1,106 @@ +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/remko_ar715/remko_ar715.h" + +namespace esphome::remko_ar715::testing { + +static constexpr int32_t BIT_MARK = 511; +static constexpr int32_t HDR_SPACE = 3492; +static constexpr int32_t ONE_SPACE = 1540; +static constexpr int32_t ZERO_SPACE = 548; +static constexpr int BITS = 52; + +/// The waveform the component must produce for a code: header, 52 bits MSB first, stop bit +static remote_base::RawTimings waveform(uint64_t code) { + remote_base::RawTimings raw; + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) { + raw.push_back(BIT_MARK); + raw.push_back((code >> i) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + return raw; +} + +/// Decodes a frame back into its code so a mismatch is readable +static uint64_t code_of(const remote_base::RawTimings &raw) { + uint64_t code = 0; + for (int bit = 0; bit < BITS; bit++) { + const size_t space = 3 + 2 * bit; + code = (code << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + return code; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + RemkoAr715Climate climate; + + Fixture() { + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.perform(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +static void expect_code(const remote_base::RawTimings &actual, uint64_t code) { + EXPECT_EQ(actual, waveform(code)) << std::hex << "actual 0x" << code_of(actual) << "\nexpected 0x" << code; +} + +// Codes captured from the AR-715 remote in IRremoteESP8266 issue 1812 +TEST(RemkoAr715, EncodesCapturedFrames) { + Fixture f; + + f.command(climate::CLIMATE_MODE_COOL, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x830000005880FULL); + + // Power off keeps the rest of the state in the frame + f.command(climate::CLIMATE_MODE_OFF, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x8000000058802ULL); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007919AULL); + + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007914FULL); +} + +// Dry and fan-only replace the fan nibbles; the fan speed is not sent +TEST(RemkoAr715, DryAndFanOnlyIgnoreTheFanSpeed) { + Fixture f; + + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A469ULL); + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A36AULL); +} + +} // namespace esphome::remko_ar715::testing diff --git a/tests/components/remko_ar715/test.esp32-idf.yaml b/tests/components/remko_ar715/test.esp32-idf.yaml new file mode 100644 index 0000000000..128f6d444a --- /dev/null +++ b/tests/components/remko_ar715/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remko_ar715: !include common.yaml diff --git a/tests/components/remko_ar715/test.esp8266-ard.yaml b/tests/components/remko_ar715/test.esp8266-ard.yaml new file mode 100644 index 0000000000..bc4c8ddda1 --- /dev/null +++ b/tests/components/remko_ar715/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remko_ar715: !include common.yaml diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 90734b72f2..f104557265 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,21 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"): + for protocol in ( + "hob2hood", + "haier", + "mirage", + "aeha", + "keeloq", + "nexus", + "ira211", + "nec", + "magiquest", + "rc5x", + "pioneer_wyt", + "drayton", + "panasonic", + ): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/drayton_test.cpp b/tests/components/remote_base/drayton_test.cpp new file mode 100644 index 0000000000..455235cd99 --- /dev/null +++ b/tests/components/remote_base/drayton_test.cpp @@ -0,0 +1,141 @@ +#include +#include +#include "esphome/components/remote_base/drayton_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr int32_t BIT_US = 500; +constexpr size_t PACKET_BITS = 28; + +// Captured packets from the header comment of drayton_protocol.cpp +constexpr uint32_t ON_PACKET = 0x6180832; +constexpr uint32_t OFF_PACKET = 0x6180052; +const char *const ON_CAPTURE = "101010101010110001101001010101101001010101010101100101010101101001011001"; +const char *const OFF_CAPTURE = "101010101010110001101001010101101001010101010101010101010110011001011001"; + +DraytonData with_data(uint16_t address, uint8_t data) { + DraytonData out{}; + out.address = address; + out.data = data; + return out; +} + +DraytonData with_legacy(uint16_t address, uint8_t channel, uint8_t command) { + DraytonData out{}; + out.address = address; + out.channel = channel; + out.command = command; + return out; +} + +RawTimings encode(const DraytonData &data) { + RemoteTransmitData tx; + DraytonProtocol().encode(&tx, data); + return tx.get_data(); +} + +// The packet bits sent after the preamble and sync, one Manchester pair per bit ('10' = 1) +uint32_t sent_packet(const RawTimings &timings) { + uint32_t word = 0; + for (size_t i = timings.size() - 2 * PACKET_BITS; i < timings.size(); i += 2) { + word = (word << 1) | (timings[i] > 0 ? 1 : 0); + } + return word; +} + +// A receiver reports runs of the same level as one item. +RawTimings as_received(const RawTimings &timings) { + RawTimings merged; + for (int32_t item : timings) { + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + return merged; +} + +RawTimings from_capture(const std::string &bits) { + RawTimings timings; + for (char bit : bits) { + timings.push_back(bit == '1' ? BIT_US : -BIT_US); + } + return as_received(timings); +} + +optional decode(const RawTimings &timings) { + return DraytonProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +} // namespace + +TEST(DraytonProtocolTest, EncodeAppendsChecksum) { + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x8))), ON_PACKET); + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x0))), OFF_PACKET); + // A lambda value above 4 bits is masked instead of spilling into the address + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x18))), ON_PACKET); +} + +TEST(DraytonProtocolTest, LegacyEncodeSendsChannelAndCommandAsIs) { + EXPECT_EQ(sent_packet(encode(with_legacy(0x6180, 0x12, 0x41))), ON_PACKET); + EXPECT_EQ(sent_packet(encode(with_legacy(0x1234, 0x01, 0x05))), 0x12340A1u); +} + +TEST(DraytonProtocolTest, DecodesCapturedPackets) { + auto on = decode(from_capture(ON_CAPTURE)); + ASSERT_TRUE(on.has_value()); + if (!on.has_value()) { + return; + } + EXPECT_EQ(on->address, 0x6180); + EXPECT_EQ(on->data, 0x8); + EXPECT_EQ(on->channel, 0x12); + EXPECT_EQ(on->command, 0x41); + + auto off = decode(from_capture(OFF_CAPTURE)); + ASSERT_TRUE(off.has_value()); + if (!off.has_value()) { + return; + } + EXPECT_EQ(off->address, 0x6180); + EXPECT_EQ(off->data, 0x0); + EXPECT_EQ(off->channel, 0x12); + EXPECT_EQ(off->command, 0x02); +} + +TEST(DraytonProtocolTest, RoundTrips) { + for (uint16_t address : {0x0000, 0x6180, 0x1231, 0xFFFF}) { + for (uint8_t data : {0x0, 0x2, 0x8, 0xA, 0xF}) { + auto decoded = decode(as_received(encode(with_data(address, data)))); + ASSERT_TRUE(decoded.has_value()) << std::hex << address << " " << int(data); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, address); + EXPECT_EQ(decoded->data, data); + } + } +} + +TEST(DraytonProtocolTest, RejectsBadChecksum) { + // On packet with the low checksum bits changed from 0x12 to 0x13 + EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x13, 0x41)))).has_value()); + // On packet with the data bits of the Off packet + EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x12, 0x01)))).has_value()); +} + +TEST(DraytonProtocolTest, BinarySensorFormsMatchDecodedPacket) { + auto on = decode(from_capture(ON_CAPTURE)); + ASSERT_TRUE(on.has_value()); + if (!on.has_value()) { + return; + } + // The decoded channel and command are ignored + EXPECT_TRUE(*on == with_data(0x6180, 0x8)); + EXPECT_FALSE(*on == with_data(0x6181, 0x8)); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/ir_code_encoders_test.cpp b/tests/components/remote_base/ir_code_encoders_test.cpp index bffea25864..b003318a1f 100644 --- a/tests/components/remote_base/ir_code_encoders_test.cpp +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -62,27 +62,15 @@ RawTimings with_trailing_space(RawTimings timings) { return timings; } -template RawTimings encode(A &action) { - RemoteTransmitData dst; - action.encode(&dst); - return dst.get_data(); -} - -// Exposes the protected encode() of an action for testing -template class Open : public A { - public: - using A::encode; -}; - -// A constant and a lambda code must both encode to the golden timings -template -void expect_static_and_lambda_match(const uint8_t *code, size_t len, std::vector (*lambda)(), - const RawTimings &golden) { - Open fixed, from_lambda; - fixed.set_code_static(code, len); - from_lambda.set_code_template(lambda); - EXPECT_EQ(encode(fixed), golden); - EXPECT_EQ(encode(from_lambda), golden); +// A constant table and a lambda's vector take the two encoder overloads generated actions call +template +void expect_pointer_and_vector_match(const uint8_t *code, size_t len, const std::vector &bytes, + const RawTimings &golden, const Lead &...lead) { + RemoteTransmitData fixed, from_vector; + P().encode(&fixed, lead..., code, len); + P().encode(&from_vector, lead..., bytes); + EXPECT_EQ(fixed.get_data(), golden); + EXPECT_EQ(from_vector.get_data(), golden); } template void expect_decodes_to(const RawTimings &timings, const std::vector &code) { @@ -94,25 +82,16 @@ template void expect_decodes_to(const RawTimings &timings, const std } // namespace -TEST(IrCodeEncodersTest, HaierStaticAndLambdaMatchGolden) { - expect_static_and_lambda_match>(HAIER_CODE, sizeof(HAIER_CODE), haier_code, HAIER_GOLDEN); +TEST(IrCodeEncodersTest, HaierPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(HAIER_CODE, sizeof(HAIER_CODE), haier_code(), HAIER_GOLDEN); } -TEST(IrCodeEncodersTest, MirageStaticAndLambdaMatchGolden) { - expect_static_and_lambda_match>(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code, MIRAGE_GOLDEN); +TEST(IrCodeEncodersTest, MiragePointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code(), MIRAGE_GOLDEN); } -TEST(IrCodeEncodersTest, AehaStaticAndLambdaMatchGolden) { - Open> fixed, lambda; - for (auto *action : {&fixed, &lambda}) { - // TemplatableFn fields take functions, not raw constants - action->set_address([]() -> uint16_t { return AEHA_ADDRESS; }); - action->set_carrier_frequency([]() -> uint32_t { return 38000; }); - } - fixed.set_data_static(AEHA_CODE, sizeof(AEHA_CODE)); - lambda.set_data_template(aeha_code); - EXPECT_EQ(encode(fixed), AEHA_GOLDEN); - EXPECT_EQ(encode(lambda), AEHA_GOLDEN); +TEST(IrCodeEncodersTest, AehaPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(AEHA_CODE, sizeof(AEHA_CODE), aeha_code(), AEHA_GOLDEN, AEHA_ADDRESS); } TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { diff --git a/tests/components/remote_base/ira211_test.cpp b/tests/components/remote_base/ira211_test.cpp new file mode 100644 index 0000000000..f23d68640d --- /dev/null +++ b/tests/components/remote_base/ira211_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/remote_base/ira211_protocol.h" + +#include +#include +#include +#include +#include + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t T_US = 800; +constexpr uint32_t HEADER_US = 7600; +constexpr size_t PREAMBLE_ITEMS = 4; // 7600 mark, 800 space, 800 mark, 7600 space +constexpr size_t BITS_PER_PACKET = 10; + +constexpr std::array ALL_COMMANDS = { + IRA211Command::IRA211_COMMAND_TEMP_UP, IRA211Command::IRA211_COMMAND_TEMP_DOWN, IRA211Command::IRA211_COMMAND_MODE, + IRA211Command::IRA211_COMMAND_FAN, IRA211Command::IRA211_COMMAND_POWER, IRA211Command::IRA211_COMMAND_SYNC, +}; +constexpr std::array ALL_MODES = { + IRA211Mode::IRA211_MODE_PROTECTION, + IRA211Mode::IRA211_MODE_TIMER, + IRA211Mode::IRA211_MODE_COMFORT, +}; +constexpr std::array ALL_FANS = { + IRA211Fan::IRA211_FAN_AUTO, + IRA211Fan::IRA211_FAN_LOW, + IRA211Fan::IRA211_FAN_MEDIUM, + IRA211Fan::IRA211_FAN_HIGH, +}; +constexpr std::array TEMPERATURES = {5.0f, 20.5f, 35.0f}; + +bool carries_temperature(IRA211Command command) { + return command != IRA211Command::IRA211_COMMAND_MODE && command != IRA211Command::IRA211_COMMAND_FAN; +} + +IRA211Data make_data(IRA211Command command, float temperature, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(temperature); + data.mode = mode; + data.fan = fan; + return data; +} + +RawTimings encode(const IRA211Data &data) { + RemoteTransmitData transmit; + IRA211Protocol().encode(&transmit, data); + return transmit.get_data(); +} + +optional decode(const RawTimings &timings) { + return IRA211Protocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +std::string describe(const IRA211Data &data) { + char buf[64]; + snprintf(buf, sizeof(buf), "command %d temperature %.1f mode %d fan %d", int(data.command), data.get_temperature(), + int(data.mode), int(data.fan)); + return buf; +} + +void expect_round_trip(const RawTimings &timings, const IRA211Data &expected) { + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << describe(expected); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_TRUE(*decoded == expected) << describe(expected) << " decoded as " << describe(*decoded); + if (carries_temperature(expected.command)) + EXPECT_EQ(decoded->get_temperature(), expected.get_temperature()) << describe(expected); + } +} + +// Index of the first run of packet `packet`, and of the run after its last one. Packets start with a 1 bit +// after a 0 end bit and end with a 0 bit before a 1 start bit, so no run crosses a packet boundary. +std::pair packet_run_range(const RawTimings &timings, size_t packet) { + size_t begin = 0, end = 0, bits = 0; + for (size_t i = PREAMBLE_ITEMS; i < timings.size(); i++) { + if (bits == packet * BITS_PER_PACKET) + begin = i; + if (bits == (packet + 1) * BITS_PER_PACKET) { + end = i; + break; + } + bits += (std::abs(timings[i]) + T_US / 2) / T_US; + } + return {begin, end}; +} + +} // namespace + +TEST(IRA211ProtocolTest, RoundTripsEveryCommandTemperatureModeAndFan) { + for (auto command : ALL_COMMANDS) { + for (auto temperature : TEMPERATURES) { + for (auto mode : ALL_MODES) { + for (auto fan : ALL_FANS) { + const IRA211Data data = make_data(command, temperature, mode, fan); + expect_round_trip(encode(data), data); + } + } + } + } +} + +TEST(IRA211ProtocolTest, EveryFrameEndsWithTheTrailingMarkAndGap) { + for (auto command : ALL_COMMANDS) { + const RawTimings timings = + encode(make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO)); + ASSERT_GE(timings.size(), PREAMBLE_ITEMS + 2) << "command " << int(command); + EXPECT_EQ(timings[timings.size() - 2], int32_t(T_US)) << "command " << int(command); + EXPECT_EQ(timings.back(), -int32_t(HEADER_US)) << "command " << int(command); + } +} + +TEST(IRA211ProtocolTest, SetTemperatureClampsAndRoundsToHalfDegrees) { + const struct { + float input; + float expected; + } cases[] = { + {4.0f, 5.0f}, {36.0f, 35.0f}, {22.26f, 22.5f}, {22.24f, 22.0f}, + {-1.0f, 5.0f}, {140.0f, 35.0f}, {5.0f, 5.0f}, {35.0f, 35.0f}, + }; + for (const auto &c : cases) { + IRA211Data data{}; + data.set_temperature(c.input); + EXPECT_EQ(data.get_temperature(), c.expected) << "input " << c.input; + } +} + +// A receiver that stops capturing at the frame gap never sees the trailing mark nor the gap after it. +TEST(IRA211ProtocolTest, DecodesWithoutTheTrailingMarkAndGap) { + for (auto command : ALL_COMMANDS) { + for (auto fan : ALL_FANS) { + const IRA211Data data = make_data(command, 35.0f, IRA211Mode::IRA211_MODE_TIMER, fan); + RawTimings timings = encode(data); + timings.pop_back(); // 7600 us gap + timings.pop_back(); // 800 us trailing mark + expect_round_trip(timings, data); + } + } +} + +// Move one bit between two neighbouring runs inside the checksum packet: the bit count and the framing bits +// stay valid, so only the checksum byte changes. +TEST(IRA211ProtocolTest, RejectsACorruptedChecksum) { + for (auto command : ALL_COMMANDS) { + const IRA211Data data = make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + RawTimings timings = encode(data); + ASSERT_TRUE(decode(timings).has_value()) << describe(data); + size_t bits = 0; + for (size_t i = PREAMBLE_ITEMS; i + 2 < timings.size(); i++) + bits += (std::abs(timings[i]) + T_US / 2) / T_US; + ASSERT_EQ(bits % BITS_PER_PACKET, 0u) << describe(data); + const auto [begin, end] = packet_run_range(timings, bits / BITS_PER_PACKET - 1); + ASSERT_GT(end, begin) << describe(data); + // Shrink the first run that is at least two bits long and grow its neighbour; a run that keeps one bit + // keeps the start or end bit it may hold. + bool corrupted = false; + for (size_t i = begin; i < end && !corrupted; i++) { + if (std::abs(timings[i]) < int32_t(2 * T_US)) + continue; + const size_t neighbour = i + 1 < end ? i + 1 : i - 1; + timings[i] -= timings[i] > 0 ? int32_t(T_US) : -int32_t(T_US); + timings[neighbour] += timings[neighbour] > 0 ? int32_t(T_US) : -int32_t(T_US); + corrupted = true; + } + ASSERT_TRUE(corrupted) << describe(data) << ": checksum packet has no run longer than one bit"; + EXPECT_FALSE(decode(timings).has_value()) << describe(data); + } +} + +// A SYNC frame carries seven packets; dropping a middle one leaves a well formed six packet frame whose +// command still claims seven, so it must be rejected. +TEST(IRA211ProtocolTest, RejectsAFrameOfTheWrongLengthForItsCommand) { + const IRA211Data data = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH); + RawTimings timings = encode(data); + ASSERT_TRUE(decode(timings).has_value()); + const auto [begin, end] = packet_run_range(timings, 3); // the tenths byte + ASSERT_GT(begin, PREAMBLE_ITEMS - 1); + ASSERT_GT(end, begin); + timings.erase(timings.begin() + begin, timings.begin() + end); + EXPECT_FALSE(decode(timings).has_value()); +} + +// transmit_state() sends POWER then SYNC back to back, so a receiver with a long idle sees both in one capture. +// The gap may be captured a little short; it must still end the first frame. +TEST(IRA211ProtocolTest, DecodesTheFirstOfTwoFramesWithAShortenedGap) { + const IRA211Data power = + make_data(IRA211Command::IRA211_COMMAND_POWER, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + const IRA211Data sync = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + RawTimings timings = encode(power); + timings.back() = -int32_t(HEADER_US - 100); + const RawTimings second = encode(sync); + timings.insert(timings.end(), second.begin(), second.end()); + expect_round_trip(timings, power); +} + +// Values are encoded as given, so a frame with an out-of-range field still gets a valid checksum. +TEST(IRA211ProtocolTest, RejectsAnOutOfRangeTemperature) { + for (uint8_t half_degrees : {uint8_t(9), uint8_t(71), uint8_t(72), uint8_t(255)}) { + IRA211Data data = make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, + IRA211Fan::IRA211_FAN_AUTO); + data.half_degrees = half_degrees; + EXPECT_FALSE(decode(encode(data)).has_value()) << "half degrees " << int(half_degrees); + } +} + +TEST(IRA211ProtocolTest, RejectsAnUnknownModeOrFan) { + IRA211Data mode = + make_data(IRA211Command::IRA211_COMMAND_MODE, 20.5f, static_cast(1), IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(decode(encode(mode)).has_value()); + IRA211Data fan = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, static_cast(1)); + EXPECT_FALSE(decode(encode(fan)).has_value()); +} + +TEST(IRA211ProtocolTest, RejectsAForeignFrame) { + const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560}; + EXPECT_FALSE(decode(nec_like).has_value()); + EXPECT_FALSE(decode({}).has_value()); +} + +TEST(IRA211ProtocolTest, EqualityIgnoresFieldsTheCommandDoesNotCarry) { + const IRA211Data temp_up_auto = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + const IRA211Data temp_up_high = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, IRA211Mode::IRA211_MODE_TIMER, + IRA211Fan::IRA211_FAN_HIGH); + EXPECT_TRUE(temp_up_auto == temp_up_high); + const IRA211Data temp_down = make_data(IRA211Command::IRA211_COMMAND_TEMP_DOWN, 20.5f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(temp_up_auto == temp_down); + const IRA211Data temp_up_colder = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.0f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(temp_up_auto == temp_up_colder); + + const IRA211Data mode_low = + make_data(IRA211Command::IRA211_COMMAND_MODE, 5.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_LOW); + const IRA211Data mode_high = + make_data(IRA211Command::IRA211_COMMAND_MODE, 35.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_HIGH); + EXPECT_TRUE(mode_low == mode_high); + + const IRA211Data sync_auto = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + const IRA211Data sync_high = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH); + EXPECT_FALSE(sync_auto == sync_high); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/magiquest_test.cpp b/tests/components/remote_base/magiquest_test.cpp new file mode 100644 index 0000000000..3641f1ec80 --- /dev/null +++ b/tests/components/remote_base/magiquest_test.cpp @@ -0,0 +1,126 @@ +#include +#include "esphome/components/remote_base/magiquest_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr int32_t UNIT = 288; +constexpr uint32_t WAND_ID = 0x2A5B3C1D; +constexpr uint16_t MAGNITUDE = 258; +constexpr MagiQuestData DECODED{.magnitude = MAGNITUDE, .wand_id = WAND_ID}; + +void add_bit(RawTimings &timings, bool one, bool last = false) { + timings.push_back(one ? 2 * UNIT : UNIT); + if (!last) { + timings.push_back(-(one ? 2 * UNIT : 3 * UNIT)); + } +} + +// Overwrite bit `bit` of a built frame with a one +void set_one(RawTimings &timings, size_t bit) { + timings[2 * bit] = 2 * UNIT; + timings[2 * bit + 1] = -2 * UNIT; +} + +// Build a frame the way a wand sends it: 8 zero header bits, a 31 bit wand id, a 9 bit magnitude and a checksum +// that makes the six payload bytes sum to zero. The last bit has no trailing space. +RawTimings build_frame(uint32_t wand_id, uint16_t magnitude, uint8_t checksum_offset = 0) { + const uint32_t id_bytes = wand_id << 1; + const uint32_t magnitude_bits = uint32_t(magnitude) << 8; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_bits >> shift); + } + const uint32_t magnitude_and_checksum = magnitude_bits | uint8_t(-sum + checksum_offset); + + RawTimings timings; + for (int i = 0; i < 8; i++) { + add_bit(timings, false); + } + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { + add_bit(timings, wand_id & mask); + } + for (uint32_t mask = 1 << 16; mask; mask >>= 1) { + add_bit(timings, magnitude_and_checksum & mask, mask == 1); + } + return timings; +} + +// The receiver's tolerance is passed for completeness; decode() replaces it with the protocol's fixed window +optional decode(const RawTimings &timings) { + return MagiQuestProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_decodes_to(const RawTimings &timings, uint32_t wand_id, uint16_t magnitude) { + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->wand_id, wand_id); + EXPECT_EQ(decoded->magnitude, magnitude); + } +} + +} // namespace + +TEST(MagiQuestProtocolTest, DecodesAValidFrame) { + expect_decodes_to(build_frame(WAND_ID, MAGNITUDE), WAND_ID, MAGNITUDE); +} + +// Magnitude 1 with wand id 0 needs a checksum of 0xFF, so the last bit is a one; magnitude 2 needs 0xFE. +TEST(MagiQuestProtocolTest, DecodesBothValuesOfTheLastBit) { + expect_decodes_to(build_frame(0, 1), 0, 1); + expect_decodes_to(build_frame(0, 2), 0, 2); +} + +TEST(MagiQuestProtocolTest, RejectsABadChecksum) { + EXPECT_FALSE(decode(build_frame(WAND_ID, MAGNITUDE, 1)).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsAFlippedDataBit) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + set_one(timings, 8); // first wand id bit, a zero in WAND_ID + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsANonZeroHeader) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + set_one(timings, 0); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsATruncatedFrame) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + timings.resize(timings.size() - 10); + EXPECT_FALSE(decode(timings).has_value()); +} + +// Receivers stretch marks and shrink spaces; 120 us is more than 25% of a unit but within the fixed window. +TEST(MagiQuestProtocolTest, DecodesWithReceiverJitterUnderTheDefaultTolerance) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + for (auto &timing : timings) { + timing += 120; + } + expect_decodes_to(timings, WAND_ID, MAGNITUDE); +} + +TEST(MagiQuestProtocolTest, MatchesAConfiguredWand) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID})); + EXPECT_FALSE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID + 1})); +} + +TEST(MagiQuestProtocolTest, MatchesAnyWandWhenNoIdIsConfigured) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = 0})); +} + +// Configs written for the old decoder hold the id without its 5 low bits. +TEST(MagiQuestProtocolTest, MatchesAnIdFromTheOldDecoder) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID >> 5})); +} + +TEST(MagiQuestProtocolTest, IgnoresMagnitudeWhenMatching) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0xFFFF, .wand_id = WAND_ID})); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/nec_protocol_test.cpp b/tests/components/remote_base/nec_protocol_test.cpp new file mode 100644 index 0000000000..6b6914ce9c --- /dev/null +++ b/tests/components/remote_base/nec_protocol_test.cpp @@ -0,0 +1,132 @@ +#include +#include +#include "esphome/components/remote_base/nec_protocol.h" + +namespace esphome::remote_base::testing { + +// NEC timings from the spec (esphome/issues#6603), independent of the encoder constants +static constexpr int32_t HEADER_MARK = 9000; +static constexpr int32_t HEADER_SPACE = -4500; +static constexpr int32_t REPEAT_HEADER_SPACE = -2250; +static constexpr int32_t BIT_MARK = 560; +static constexpr int32_t ONE_SPACE = -1690; +static constexpr int32_t ZERO_SPACE = -560; +static constexpr int32_t REPEAT_PERIOD = 108000; +static constexpr int32_t REPEAT_GAP = -(REPEAT_PERIOD - (HEADER_MARK - REPEAT_HEADER_SPACE + BIT_MARK)); + +// Builds a full NEC frame by hand: header, address and command LSB first, stop mark +static std::vector build_frame(uint16_t address, uint16_t command) { + std::vector raw{HEADER_MARK, HEADER_SPACE}; + for (uint16_t value : {address, command}) { + for (int bit = 0; bit < 16; bit++) { + raw.push_back(BIT_MARK); + raw.push_back((value >> bit) & 1 ? ONE_SPACE : ZERO_SPACE); + } + } + raw.push_back(BIT_MARK); + return raw; +} + +/// The space that puts the first repeat frame 108 ms after the start of this frame +static int32_t first_repeat_gap(const std::vector &frame) { + int32_t duration = 0; + for (int32_t item : frame) { + duration += item < 0 ? -item : item; + } + return -(REPEAT_PERIOD - duration); +} + +TEST(NECProtocolTest, EncodeWithoutRepeats) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x7F80, .command = 0xF20D, .command_repeats = 1}); + + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(dst.get_data(), build_frame(0x7F80, 0xF20D)); +} + +TEST(NECProtocolTest, EncodeWithRepeats) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x7F80, .command = 0xF20D, .command_repeats = 3}); + + std::vector expected = build_frame(0x7F80, 0xF20D); + expected.insert(expected.end(), {first_repeat_gap(expected), HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}); + expected.insert(expected.end(), {REPEAT_GAP, HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}); + EXPECT_EQ(dst.get_data(), expected); +} + +TEST(NECProtocolTest, FirstRepeatGapFollowsFrameLength) { + NECProtocol protocol; + RemoteTransmitData all_zeros; + protocol.encode(&all_zeros, NECData{.address = 0, .command = 0, .command_repeats = 2}); + RemoteTransmitData all_ones; + protocol.encode(&all_ones, NECData{.address = 0xFFFF, .command = 0xFFFF, .command_repeats = 2}); + + const std::vector zeros_frame = build_frame(0, 0); + const std::vector ones_frame = build_frame(0xFFFF, 0xFFFF); + EXPECT_EQ(all_zeros.get_data()[zeros_frame.size()], first_repeat_gap(zeros_frame)); + EXPECT_EQ(all_ones.get_data()[ones_frame.size()], first_repeat_gap(ones_frame)); + // Both frames start their repeat 108 ms after they began + EXPECT_EQ(first_repeat_gap(zeros_frame) - first_repeat_gap(ones_frame), + static_cast(32 * (ONE_SPACE - ZERO_SPACE))); +} + +TEST(NECProtocolTest, EncodeClampsRepeats) { + NECProtocol protocol; + RemoteTransmitData none; + protocol.encode(&none, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 0}); + EXPECT_EQ(none.get_data().size(), 67u); + + RemoteTransmitData many; + protocol.encode(&many, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 500}); + EXPECT_EQ(many.get_data().size(), 67u + 4u * 9u); +} + +TEST(NECProtocolTest, EncodeReserveMatchesSize) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 5}); + + EXPECT_EQ(dst.get_data().size(), 67u + 4u * 4u); + EXPECT_GE(dst.get_data().capacity(), dst.get_data().size()); +} + +TEST(NECProtocolTest, DecodeFullFrame) { + NECProtocol protocol; + std::vector raw = build_frame(0x1234, 0x5678); + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + auto decoded = protocol.decode(rx); + + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access does not model gtest's ASSERT_TRUE as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234); + EXPECT_EQ(decoded->command, 0x5678); + EXPECT_EQ(decoded->command_repeats, 1); + } +} + +TEST(NECProtocolTest, DecodeWithoutStopMark) { + NECProtocol protocol; + std::vector raw = build_frame(0x1234, 0x5678); + raw.pop_back(); + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + auto decoded = protocol.decode(rx); + + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234); + EXPECT_EQ(decoded->command, 0x5678); + } +} + +TEST(NECProtocolTest, DecodeStandaloneRepeatFrameRejected) { + NECProtocol protocol; + std::vector raw{HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}; + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + + EXPECT_FALSE(protocol.decode(rx).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/nexus_test.cpp b/tests/components/remote_base/nexus_test.cpp new file mode 100644 index 0000000000..b29bd00943 --- /dev/null +++ b/tests/components/remote_base/nexus_test.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include "esphome/components/remote_base/nexus_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +RawTimings encode(const NexusData &data) { + RemoteTransmitData tx; + NexusProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return NexusProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(float temperature, bool drop_sync) { + const NexusData sent{.temperature = temperature, + .address = 0x85, + .battery_level = true, + .force_update = false, + .channel = 2, + .humidity = 69}; + RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), 2u + 36u * 2u); + if (drop_sync) { + timings.resize(timings.size() - 2); + } else { + // The receiver kept the previous frame's sync in front of this one + timings.insert(timings.begin(), timings.end() - 2, timings.end()); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << temperature; + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, temperature); + EXPECT_EQ(decoded->address, 0x85); + EXPECT_TRUE(decoded->battery_level); + EXPECT_FALSE(decoded->force_update); + EXPECT_EQ(decoded->channel, 2); + EXPECT_EQ(decoded->humidity, 69); +} + +} // namespace + +TEST(NexusProtocolTest, RoundTripsWithLeadingSync) { + expect_round_trip(13.8f, false); + expect_round_trip(-12.3f, false); + expect_round_trip(-204.8f, false); + expect_round_trip(204.7f, false); +} + +TEST(NexusProtocolTest, RoundTripsWithoutTrailingSync) { + expect_round_trip(0.0f, true); + expect_round_trip(-0.1f, true); +} + +TEST(NexusProtocolTest, ClampsOutOfRangeTemperature) { + auto decoded = decode(encode(NexusData{.temperature = 300.0f, .channel = 1})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, 204.7f); + decoded = decode(encode(NexusData{.temperature = NAN, .channel = 1})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, 0.0f); +} + +TEST(NexusProtocolTest, RejectsWrongConstantBits) { + RawTimings timings = encode(NexusData{.temperature = 20.0f, .channel = 1}); + // Constant bits are data bits 24-27; each bit is a mark and a space, the sync comes last. + timings[24 * 2 + 1] = -980; + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/panasonic_test.cpp b/tests/components/remote_base/panasonic_test.cpp new file mode 100644 index 0000000000..dfdf0e5069 --- /dev/null +++ b/tests/components/remote_base/panasonic_test.cpp @@ -0,0 +1,100 @@ +#include +#include "esphome/components/remote_base/panasonic_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const PanasonicData FRAME_48{0x4004, 0x0100BCBD}; +const PanasonicData FRAME_56{0x4004, 0x0100BCBD, 56, 0x12}; + +RawTimings encode(const PanasonicData &data) { + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return PanasonicProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +} // namespace + +TEST(PanasonicProtocolTest, ForwardsTheConfiguredCarrier) { + PanasonicData data = FRAME_56; + data.carrier_frequency = 36000; + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, data); + EXPECT_EQ(tx.get_carrier_frequency(), 36000u); +} + +TEST(PanasonicProtocolTest, RoundTrips48BitFrame) { + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, FRAME_48); + EXPECT_EQ(tx.get_data().size(), 2u + 2u * 48u + 1u); + EXPECT_EQ(tx.get_carrier_frequency(), 35000u); + auto decoded = decode(tx.get_data()); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_48); +} + +TEST(PanasonicProtocolTest, RoundTrips56BitFrame) { + RawTimings timings = encode(FRAME_56); + EXPECT_EQ(timings.size(), 2u + 2u * 56u + 1u); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_56); +} + +TEST(PanasonicProtocolTest, Decodes56BitFrameWithTrailingItems) { + RawTimings timings = encode(FRAME_56); + timings.insert(timings.end(), {-20000, 3502, -1750, 502, -400}); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_56); +} + +TEST(PanasonicProtocolTest, Decodes48BitFrameWithTrailingItems) { + RawTimings timings = encode(FRAME_48); + // A repeat frame arriving inside the idle window. + timings.insert(timings.end(), {-20000, 3502, -1750, 502, -400}); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_48); +} + +TEST(PanasonicProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = encode(FRAME_48); + timings.resize(timings.size() - 4); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(PanasonicProtocolTest, InvalidNbitsSendsNothing) { + PanasonicData data = FRAME_48; + data.nbits = 40; + EXPECT_TRUE(encode(data).empty()); +} + +TEST(PanasonicProtocolTest, EqualityIgnoresCarrier) { + PanasonicData other_carrier = FRAME_56; + other_carrier.carrier_frequency = 36000; + EXPECT_EQ(FRAME_56, other_carrier); + PanasonicData other_address_2 = FRAME_56; + other_address_2.address_2 = 0x13; + EXPECT_NE(FRAME_56, other_address_2); + EXPECT_NE(FRAME_48, FRAME_56); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/pioneer_wyt_test.cpp b/tests/components/remote_base/pioneer_wyt_test.cpp new file mode 100644 index 0000000000..3854356546 --- /dev/null +++ b/tests/components/remote_base/pioneer_wyt_test.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include "esphome/components/remote_base/pioneer_wyt_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +using Frame = std::array; + +// Factory remote capture: cool mode, 26°C, beeper on, display on; the last byte is the checksum +constexpr Frame COOL_26_CAPTURE = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1}; + +Frame bytes(const PioneerWytData &data) { + Frame out{}; + std::copy_n(data.data(), out.size(), out.begin()); + return out; +} + +} // namespace + +TEST(PioneerWytProtocolTest, RawCodeGetsTheCaptureChecksum) { + PioneerWytData data(std::vector(COOL_26_CAPTURE.begin(), COOL_26_CAPTURE.end() - 1)); + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +TEST(PioneerWytProtocolTest, GivenChecksumIsRecomputed) { + PioneerWytData data(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size()); + data[PioneerWytData::SIZE - 1] = 0x00; + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +// FAN frames add 0x0F to the byte sum +TEST(PioneerWytProtocolTest, FanFrameChecksumHasOffset) { + PioneerWytData data{0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, 0x10, 0xC0, 0x00, 0x00, 0x00, 0x00}; + EXPECT_EQ(data.type(), PioneerWytData::PIONEER_WYT_TYPE_FAN); + data.finalize(); + EXPECT_EQ(data[PioneerWytData::SIZE - 1], 0xF5); +} + +TEST(PioneerWytProtocolTest, EncodesHeaderAndAllBits) { + RemoteTransmitData dst; + PioneerWytProtocol().encode(&dst, PioneerWytData(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size())); + // header mark and space, a mark and space per bit, then the trailing mark + const auto &raw = dst.get_data(); + ASSERT_EQ(raw.size(), 3u + PioneerWytData::SIZE * 8 * 2); + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(raw[0], 3100); + EXPECT_EQ(raw[1], -1650); + // 0x23 LSB first: bit 0 is one, bit 2 is zero + EXPECT_EQ(raw[3], -1100); + EXPECT_EQ(raw[7], -350); + EXPECT_EQ(raw.back(), 500); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/rc5x_test.cpp b/tests/components/remote_base/rc5x_test.cpp new file mode 100644 index 0000000000..44ab365f97 --- /dev/null +++ b/tests/components/remote_base/rc5x_test.cpp @@ -0,0 +1,164 @@ +#include +#include "esphome/components/remote_base/rc5x_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +RawTimings encode(const RC5XData &data) { + RemoteTransmitData tx; + RC5XProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark, sees runs of one level as one item, and may drop a trailing space. +RawTimings as_received(const RawTimings &timings, bool drop_tail = false) { + RawTimings merged; + for (int32_t item : timings) { + if (merged.empty() && item < 0) { + continue; + } + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + if (drop_tail && !merged.empty() && merged.back() < 0) { + merged.pop_back(); + } + return merged; +} + +RawTimings inverted(RawTimings timings) { + for (int32_t &item : timings) { + item = -item; + } + return timings; +} + +optional decode(const RawTimings &timings) { + return RC5XProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(const RC5XData &sent) { + // encode() flips the toggle on every call, so two calls cover both toggle values + const RawTimings first = encode(sent); + const RawTimings second = encode(sent); + ASSERT_NE(first, second); + for (const RawTimings &timings : {first, second}) { + for (bool drop_tail : {false, true}) { + auto decoded = decode(as_received(timings, drop_tail)); + ASSERT_TRUE(decoded.has_value()) << std::hex << "address 0x" << int(sent.address) << " command 0x" + << int(sent.command) << " extension 0x" << int(sent.extension); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, sent); + } + } +} + +} // namespace + +TEST(RC5XProtocolTest, RoundTripsBothToggleValues) { + expect_round_trip({.address = 0x10, .command = 0x00, .extension = 0x06}); + expect_round_trip({.address = 0x1F, .command = 0x0D, .extension = 0x15}); +} + +TEST(RC5XProtocolTest, RoundTripsCommandsWithBit5Set) { + expect_round_trip({.address = 0x10, .command = 0x20, .extension = 0x00}); + expect_round_trip({.address = 0x01, .command = 0x3F, .extension = 0x2A}); + expect_round_trip({.address = 0x00, .command = 0x25, .extension = 0x3F}); +} + +TEST(RC5XProtocolTest, RoundTripsExtendedCommands) { + expect_round_trip({.address = 0x10, .command = 0x40, .extension = 0x06}); + expect_round_trip({.address = 0x0A, .command = 0x55, .extension = 0x00}); + expect_round_trip({.address = 0x15, .command = 0x7F, .extension = 0x3F}); + expect_round_trip({.address = 0x10, .command = 0x60, .extension = 0x01}); +} + +TEST(RC5XProtocolTest, RoundTripsExtensionLimits) { + for (uint8_t address : {0x00, 0x1F}) { + for (uint8_t command : {0x00, 0x1F, 0x20, 0x3F, 0x40, 0x7F}) { + expect_round_trip({.address = address, .command = command, .extension = 0x00}); + expect_round_trip({.address = address, .command = command, .extension = 0x3F}); + } + } +} + +TEST(RC5XProtocolTest, DecodesCapturedMarantzAux1) { + // Captured AUX1 code (address 0x10, command 0x00, extension 0x06, toggle set) from the PR description + const RawTimings captured = {889, -889, 889, -889, 889, -889, 1778, -889, 889, -889, 889, -889, + 889, -4450, 889, -889, 889, -889, 889, -889, 889, -889, 889, -889, + 889, -889, 889, -889, 889, -889, 889, -1778, 889, -889, 1778}; + auto decoded = decode(captured); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x10); + EXPECT_EQ(decoded->command, 0x00); + EXPECT_EQ(decoded->extension, 0x06); +} + +TEST(RC5XProtocolTest, EncodeMatchesCapturedMarantzAux1) { + const RawTimings captured = {889, -889, 889, -889, 889, -889, 1778, -889, 889, -889, 889, -889, + 889, -4450, 889, -889, 889, -889, 889, -889, 889, -889, 889, -889, + 889, -889, 889, -889, 889, -889, 889, -1778, 889, -889, 1778}; + const RC5XData aux1{.address = 0x10, .command = 0x00, .extension = 0x06}; + // The capture has the toggle set; one of two consecutive frames does too + const RawTimings first = as_received(encode(aux1), true); + const RawTimings second = as_received(encode(aux1), true); + const RawTimings &toggled = first[6] == 1778 ? first : second; + ASSERT_EQ(toggled.size(), captured.size()); + for (size_t i = 0; i < captured.size(); i++) { + // The pause is 4 x 889 plus the half-bit before it, sent as 4445 against 4450 captured + EXPECT_NEAR(toggled[i], captured[i], 5) << i; + } +} + +TEST(RC5XProtocolTest, DecodesAtInvertedPolarity) { + const RC5XData sent{.address = 0x10, .command = 0x6A, .extension = 0x2B}; + // With the pin inverted, every carrier half reads as a space + const RawTimings timings = inverted(as_received(encode(sent), true)); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, sent); +} + +TEST(RC5XProtocolTest, MasksOutOfRangeFields) { + // Bit 7 of the command has no place on the wire and is dropped like the other high bits + RC5XData sent{.address = 0xF0, .command = 0xC5, .extension = 0xC6}; + auto decoded = decode(as_received(encode(sent))); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x10); + EXPECT_EQ(decoded->command, 0x45); + EXPECT_EQ(decoded->extension, 0x06); +} + +TEST(RC5XProtocolTest, RejectsPlainRc5AndBrokenFrames) { + // Without the pause the frame is too short + RawTimings timings = as_received(encode({.address = 0x10, .command = 0x00, .extension = 0x06})); + RawTimings no_pause; + for (int32_t item : timings) { + no_pause.push_back(item < -3000 ? -889 : item); + } + EXPECT_FALSE(decode(no_pause).has_value()); + // A pause far too long + for (int32_t &item : timings) { + if (item < -3000) { + item = -9000; + } + } + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 55f489505c..da88548d2a 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -51,8 +51,8 @@ on_dooya: on_drayton: then: - logger.log: - format: "on_drayton: %u %u %u" - args: ["x.address", "x.channel", "x.command"] + format: "on_drayton: %u %u %u %u" + args: ["x.address", "x.channel", "x.command", "x.data"] on_dyson: then: - logger.log: @@ -103,6 +103,11 @@ on_nexa: - logger.log: format: "on_nexa: %lu %u %u %u %u" args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"] +on_nexus: + then: + - logger.log: + format: "on_nexus: %u %u %.1f %u %d %d" + args: ["x.channel", "x.address", "x.temperature", "x.humidity", "x.battery_level", "x.force_update"] on_onkyori: then: - logger.log: @@ -111,8 +116,8 @@ on_onkyori: on_panasonic: then: - logger.log: - format: "on_panasonic: %u %lu" - args: ["x.address", "long(x.command)"] + format: "on_panasonic: %u %u %u %lu" + args: ["x.nbits", "x.address", "x.address_2", "long(x.command)"] on_pioneer: then: - logger.log: @@ -133,6 +138,11 @@ on_rc5: - logger.log: format: "on_rc5: %u %u" args: ["x.address", "x.command"] +on_rc5x: + then: + - logger.log: + format: "on_rc5x: %u %u %u" + args: ["x.address", "x.command", "x.extension"] on_rc6: then: - logger.log: @@ -182,3 +192,13 @@ on_hob2hood: - logger.log: format: "on_hob2hood: %u" args: ["x.command"] +on_pd_pioneer: + then: + - logger.log: + format: "on_pd_pioneer: burst %u power %u" + args: ["x[3]", "x[5]"] +on_ira211: + then: + - logger.log: + format: "on_ira211: %u %.1f" + args: ["static_cast(x.command)", "x.get_temperature()"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 956cfdc323..edd5ee3647 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,7 +15,54 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: RC5X Remote Input + rc5x: + address: 0x00 + command: 0x0D + extension: 0x03 + - platform: remote_receiver + name: Drayton Thermostat On + drayton: + address: 0x6180 + data: 0x8 + - platform: remote_receiver + name: Drayton Thermostat On Legacy + drayton: + address: 0x6180 + channel: 0x12 + command: 0x41 + - platform: remote_receiver + name: Panasonic 56 bit Remote Input + panasonic: + nbits: 56 + address: 0x4004 + address_2: 0x12 + command: 0x100BCBD - platform: remote_receiver name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: IRA211 Sync + ira211: + command: sync + temperature: 21.5 + mode: comfort + fan_mode: low + - platform: remote_receiver + name: MagiQuest Wand + magiquest: + wand_id: 0x0F1E2D3C + - platform: remote_receiver + name: MagiQuest Any Wand + magiquest: {} + - platform: remote_receiver + name: Nexus Sensor + nexus: + channel: 1 + address: 0x42 + - platform: remote_receiver + name: PD Pioneer Cool + pd_pioneer: + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80] diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f168a825d3..66ee803d4f 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -27,6 +27,13 @@ button: on_press: remote_transmitter.transmit_brennenstuhl: code: 0xBD2E2C + - platform: template + name: Drayton Thermostat On + id: drayton_thermostat_on + on_press: + remote_transmitter.transmit_drayton: + address: 0x1230 + data: 0x8 - platform: template name: Dyson fan up id: dyson_fan_up @@ -52,6 +59,16 @@ button: remote_transmitter.transmit_nec: address: 0x4242 command: 0x8484 + - platform: template + name: Nexus + on_press: + remote_transmitter.transmit_nexus: + channel: 1 + address: 0x42 + temperature: -12.3 + humidity: 69 + battery_level: false + force_update: true - platform: template name: LG on_press: @@ -75,6 +92,14 @@ button: - remote_transmitter.transmit_toshiba_ac: rc_code_1: 0xB24DBF4050AF rc_code_2: 0xD5660001003C + - platform: template + name: IRA211 + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: 22.5 + mode: timer + fan_mode: high - platform: template name: Sony on_press: @@ -93,6 +118,15 @@ button: remote_transmitter.transmit_panasonic: address: 0x4004 command: 0x1000BCD + - platform: template + name: Panasonic 56 bit + on_press: + remote_transmitter.transmit_panasonic: + nbits: 56 + address: 0x4004 + address_2: 0x12 + command: !lambda return 0x1000BCD; + carrier_frequency: 36kHz - platform: template name: Pioneer on_press: @@ -101,6 +135,18 @@ button: rc_code_2: 0xA506 repeat: times: 2 + - platform: template + name: Pioneer WYT + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Captured from the factory remote: cool mode, 26°C. The encoder appends the checksum. + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80] + - platform: template + name: Pioneer WYT With Checksum + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Same capture with its checksum byte included + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1] - platform: template name: RC Switch Raw on_press: @@ -159,6 +205,13 @@ button: remote_transmitter.transmit_raw: code: !lambda |- return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; + - platform: template + name: RC5X Marantz + on_press: + remote_transmitter.transmit_rc5x: + address: 0x10 + command: 0x0 + extension: 0x6 - platform: template name: AEHA id: eaha_hitachi_climate_power_on @@ -332,3 +385,154 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq lambda with repeat + on_press: + remote_transmitter.transmit_keeloq: + address: !lambda return 0x57ffe7b; + command: 0x02 + code: 0xd19ef0a9 + repeat: + times: 3 + wait_time: 10ms + - platform: template + name: Toto with repeat + on_press: + remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + repeat: + times: 2 + wait_time: 20ms + - platform: template + name: ByronSX + on_press: + remote_transmitter.transmit_byronsx: + address: 0x12 + command: 0x05 + - platform: template + name: CanalSat + on_press: + remote_transmitter.transmit_canalsat: + device: 0x25 + address: 0x01 + command: 0x1C + - platform: template + name: CanalSatLD + on_press: + remote_transmitter.transmit_canalsatld: + device: 0x25 + command: !lambda return 0x1C; + - platform: template + name: Coolix + on_press: + remote_transmitter.transmit_coolix: + first: 0xB2BF40 + second: 0xB2BF40 + - platform: template + name: Dish + on_press: + remote_transmitter.transmit_dish: + address: 1 + command: 16 + - platform: template + name: Drayton + on_press: + remote_transmitter.transmit_drayton: + address: 0x1234 + channel: 0x01 + command: 0x05 + - platform: template + name: Gobox + on_press: + remote_transmitter.transmit_gobox: + code: 1 + - platform: template + name: Nexa + on_press: + remote_transmitter.transmit_nexa: + device: 0x38DDB5A + group: 0 + state: 1 + channel: 15 + level: 0 + - platform: template + name: Roomba + on_press: + remote_transmitter.transmit_roomba: + data: 0x88 + - platform: template + name: Pronto + on_press: + remote_transmitter.transmit_pronto: + data: "0000 006D 0001 0000 0015 0016" + - platform: template + name: RC6 + on_press: + remote_transmitter.transmit_rc6: + address: 0x00 + command: !lambda return 0x0C; + - platform: template + name: Midea + on_press: + remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: Midea lambda + on_press: + remote_transmitter.transmit_midea: + code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Haier lambda + on_press: + remote_transmitter.transmit_haier: + code: !lambda return {0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; + - platform: template + name: Mirage lambda + on_press: + remote_transmitter.transmit_mirage: + code: !lambda return {0x56, 0x77, 0x00, 0x00, 0x22, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + - platform: template + name: AEHA lambda + on_press: + remote_transmitter.transmit_aeha: + address: 0x8008 + data: !lambda return {0x00, 0x02, 0xFD, 0xFF}; + - platform: template + name: ABBWelcome fixed message id + on_press: + remote_transmitter.transmit_abbwelcome: + source_address: 0x123456 + destination_address: 0x654321 + three_byte_address: true + retransmission: true + message_type: 0x01 + message_id: 0x42 + data: [0x10, 0x20] + - platform: template + name: SomfyRTS + on_press: + - remote_transmitter.transmit_somfy_rts: + address: 0x630139 + control: 0x08 + key: 0xA0 + rolling_code: 1234 + control_in_key: false + - platform: template + name: SomfyRTS Control In Key + on_press: + - remote_transmitter.transmit_somfy_rts: + address: 0x630139 + control: 0x02 + key: 0xA0 + rolling_code: !lambda return 1235; + control_in_key: true + repeat: + times: 3 + wait_time: 30ms + - platform: template + name: PD Pioneer + on_press: + - remote_transmitter.transmit_pd_pioneer: + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80] diff --git a/tests/components/renogy_inverter_ble/common.yaml b/tests/components/renogy_inverter_ble/common.yaml new file mode 100644 index 0000000000..a4f6e8e16f --- /dev/null +++ b/tests/components/renogy_inverter_ble/common.yaml @@ -0,0 +1,31 @@ +ble_client: + - mac_address: 14:9C:EF:01:02:03 + id: inverter_client + +renogy_inverter_ble: + - id: inv0 + ble_client_id: inverter_client + +sensor: + - platform: renogy_inverter_ble + renogy_inverter_ble_id: inv0 + ac_input_voltage: + name: AC input voltage + ac_output_voltage: + name: AC output voltage + ac_output_current: + name: AC output current + ac_output_frequency: + name: AC output frequency + input_frequency: + name: Input frequency + battery_voltage: + name: Battery voltage + temperature: + name: Temperature + load_current: + name: Load current + load_active_power: + name: Load active power + load_apparent_power: + name: Load apparent power diff --git a/tests/components/renogy_inverter_ble/test.esp32-idf.yaml b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..5ef5be8856 --- /dev/null +++ b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + renogy_inverter_ble: !include common.yaml diff --git a/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml new file mode 100644 index 0000000000..25135f1d39 --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2040_pio_led_strip: !include common.yaml diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp index e62e2a98d9..9f7190980b 100644 --- a/tests/components/runtime_image/test_decoder_reuse.cpp +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -101,6 +101,9 @@ class TestableRuntimeImage : public RuntimeImage { explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE) : RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {} + TestableRuntimeImage(ImageFormat format, image::ImageType type, bool is_big_endian, int fixed_width, int fixed_height) + : RuntimeImage(format, type, image::TRANSPARENCY_OPAQUE, nullptr, is_big_endian, fixed_width, fixed_height) {} + ImageDecoder *decoder() { return this->decoder_.get(); } }; @@ -373,6 +376,87 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { EXPECT_EQ(img.decoder(), first); EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; } + +// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each +// channel is checked within one step; the byte order is still exact +static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) { + EXPECT_NEAR(actual >> 11, expected >> 11, 1) << order << " pixel " << pixel << " red"; + EXPECT_NEAR((actual >> 5) & 0x3F, (expected >> 5) & 0x3F, 1) << order << " pixel " << pixel << " green"; + EXPECT_NEAR(actual & 0x1F, expected & 0x1F, 1) << order << " pixel " << pixel << " blue"; +} + +TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) { + // Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those + // pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source + // pixel (2, 0), which is word 0x3803 here. + static const uint16_t EXPECTED[4 * 4] = { + 0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F, + 0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17, + }; + TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4); + TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4); + + ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(rgb565_little_endian.get_width(), 4); + ASSERT_EQ(rgb565_little_endian.get_height(), 4); + ASSERT_EQ(rgb565_big_endian.get_width(), 4); + ASSERT_EQ(rgb565_big_endian.get_height(), 4); + + const uint8_t *little = rgb565_little_endian.get_data_start(); + const uint8_t *big = rgb565_big_endian.get_data_start(); + for (size_t i = 0; i < 4 * 4; i++) { + expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i); + expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i); + } +} + +// Target pixel index each source pixel paints when `source` pixels map onto `target`, the way +// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start +static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; } + +// The RGB565 word at (x, y) of a decoded image, in its own byte order +static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) { + const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2; + return p[0] | (p[1] << 8); +} + +// Every scaled direct decode must equal the unscaled one sampled through the boundary table, +// for shrinking, growing and a ratio that is not a whole number +TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) { + TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8); + ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + + for (int size : {4, 6, 16}) { + TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size); + ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(scaled.get_width(), size); + ASSERT_EQ(scaled.get_height(), size); + for (int n = 0; n < 8; n++) { + for (int m = 0; m < 8; m++) { + for (int y = scaled_boundary(m, 8, size); y < scaled_boundary(m + 1, 8, size); y++) { + for (int x = scaled_boundary(n, 8, size); x < scaled_boundary(n + 1, 8, size); x++) { + EXPECT_EQ(rgb565_word(scaled, x, y), rgb565_word(full, n, m)) + << size << "x" << size << " pixel " << x << "," << y; + } + } + } + } + } +} + +// A second decode into the same image reuses the boundary tables and gives the same pixels +TEST(RuntimeImageDecoder, JpegDirectRgb565RepeatsWithReusedTables) { + TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, 6, 6); + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels); +} #endif // USE_RUNTIME_IMAGE_JPEG TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) { diff --git a/tests/components/samsung/common.yaml b/tests/components/samsung/common.yaml new file mode 100644 index 0000000000..534500ca3a --- /dev/null +++ b/tests/components/samsung/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: samsung + name: SAMSUNG Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/samsung/test.esp32-idf.yaml b/tests/components/samsung/test.esp32-idf.yaml new file mode 100644 index 0000000000..193f830e47 --- /dev/null +++ b/tests/components/samsung/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + samsung: !include common.yaml diff --git a/tests/components/samsung/test.esp8266-ard.yaml b/tests/components/samsung/test.esp8266-ard.yaml new file mode 100644 index 0000000000..451bf93b94 --- /dev/null +++ b/tests/components/samsung/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + samsung: !include common.yaml diff --git a/tests/components/select/common-triggers.yaml b/tests/components/select/common-triggers.yaml new file mode 100644 index 0000000000..bc3510950c --- /dev/null +++ b/tests/components/select/common-triggers.yaml @@ -0,0 +1,14 @@ +# on_value with both trigger arguments. +select: + - platform: template + name: Select Triggers + id: select_triggers + optimistic: true + options: + - one + - two + - three + on_value: + - logger.log: + format: "Selected %s at %zu" + args: [x.c_str(), i] diff --git a/tests/components/select/test-triggers.esp32-idf.yaml b/tests/components/select/test-triggers.esp32-idf.yaml new file mode 100644 index 0000000000..17b435f5a2 --- /dev/null +++ b/tests/components/select/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + select: !include common.yaml + triggers: !include common-triggers.yaml diff --git a/tests/components/sh1122_spi/common.yaml b/tests/components/sh1122_spi/common.yaml new file mode 100644 index 0000000000..a48a1a5711 --- /dev/null +++ b/tests/components/sh1122_spi/common.yaml @@ -0,0 +1,19 @@ +display: + - platform: sh1122_spi + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${reset_pin} + brightness: 80% + pages: + - id: sh1122_spi_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: sh1122_spi_page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: sh1122_spi_page1 + to: sh1122_spi_page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/sh1122_spi/test.esp32-idf.yaml b/tests/components/sh1122_spi/test.esp32-idf.yaml new file mode 100644 index 0000000000..10efaac7f4 --- /dev/null +++ b/tests/components/sh1122_spi/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + reset_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.esp8266-ard.yaml b/tests/components/sh1122_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..bc1105479d --- /dev/null +++ b/tests/components/sh1122_spi/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + dc_pin: GPIO15 + reset_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.rp2040-ard.yaml b/tests/components/sh1122_spi/test.rp2040-ard.yaml new file mode 100644 index 0000000000..91ec731ca9 --- /dev/null +++ b/tests/components/sh1122_spi/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO20 + dc_pin: GPIO21 + reset_pin: GPIO22 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/siemens_ira211/common.yaml b/tests/components/siemens_ira211/common.yaml new file mode 100644 index 0000000000..fc2c53f967 --- /dev/null +++ b/tests/components/siemens_ira211/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: siemens_ira211 + name: Siemens IRA211 + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/siemens_ira211/test.esp32-idf.yaml b/tests/components/siemens_ira211/test.esp32-idf.yaml new file mode 100644 index 0000000000..b217e0f0d9 --- /dev/null +++ b/tests/components/siemens_ira211/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + siemens_ira211: !include common.yaml diff --git a/tests/components/siemens_ira211/test.esp8266-ard.yaml b/tests/components/siemens_ira211/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b5b3d45c10 --- /dev/null +++ b/tests/components/siemens_ira211/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + siemens_ira211: !include common.yaml diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp index 31fe286a86..d730e3a4a6 100644 --- a/tests/components/socket/test_multicast_host.cpp +++ b/tests/components/socket/test_multicast_host.cpp @@ -46,10 +46,14 @@ TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { } TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) { - auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - ASSERT_NE(sock, nullptr); - errno = 0; - EXPECT_TRUE(join_multicast_group(sock.get(), "239.0.60.53", nullptr) || errno != 0); + // Whether the host accepts the join or not, a null if_index_out must give the same answer + auto sock_with = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + auto sock_without = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_with, nullptr); + ASSERT_NE(sock_without, nullptr); + uint32_t if_index = 0; + const bool with_out = join_multicast_group(sock_with.get(), "239.0.60.53", &if_index); + EXPECT_EQ(join_multicast_group(sock_without.get(), "239.0.60.53", nullptr), with_out); } // ========================================================================= @@ -62,11 +66,13 @@ TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) { auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); ASSERT_NE(sock, nullptr); uint32_t if_index = 0; - bool ok = join_multicast_group(sock.get(), "ff12::cafe", &if_index); - if (ok) { + errno = 0; + if (join_multicast_group(sock.get(), "ff12::cafe", &if_index)) { EXPECT_GT(if_index, 0u); - } else { - EXPECT_EQ(errno, EADDRNOTAVAIL); + } else if (errno != EADDRNOTAVAIL) { + // EINVAL can only come from our own parsing, since the address literal is valid + ASSERT_NE(errno, EINVAL); + GTEST_SKIP() << "Host refused the IPv6 multicast join, errno " << errno; } } @@ -74,7 +80,9 @@ TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) { auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); ASSERT_NE(sock, nullptr); errno = 0; - EXPECT_TRUE(set_ipv6_multicast_if(sock.get(), 0) || errno == EADDRNOTAVAIL); + if (!set_ipv6_multicast_if(sock.get(), 0) && errno != EADDRNOTAVAIL) { + GTEST_SKIP() << "Host refused IPV6_MULTICAST_IF, errno " << errno; + } } TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { @@ -83,10 +91,10 @@ TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { ASSERT_NE(sock_join, nullptr); ASSERT_NE(sock_send, nullptr); uint32_t if_index = 0; - bool joined = join_multicast_group(sock_join.get(), "ff12::cafe", &if_index); - if (joined && if_index != 0) { - EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); + if (!join_multicast_group(sock_join.get(), "ff12::cafe", &if_index) || if_index == 0) { + GTEST_SKIP() << "Host has no interface that accepts an IPv6 multicast join"; } + EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); } #endif // USE_NETWORK_IPV6 diff --git a/tests/components/tca8418/common.yaml b/tests/components/tca8418/common.yaml new file mode 100644 index 0000000000..4a6b7150c4 --- /dev/null +++ b/tests/components/tca8418/common.yaml @@ -0,0 +1,36 @@ +tca8418: + # A 4x3 key matrix, with the remaining pins reporting as individual inputs. + - id: matrix_keypad_device + i2c_id: i2c_bus + address: 0x34 + rows: 4 + columns: 3 + keys: "123456789*0#" + # The interrupt output is open drain, so it needs a pull-up. Use the + # internal one unless the board already has its own. + interrupt_pin: + number: ${interrupt_pin} + mode: INPUT_PULLUP + on_key: + - logger.log: + format: "Key: %c" + args: [x] + # Every pin reporting as an individual input, polled over I2C. + - id: inputs_only_device + i2c_id: i2c_bus + address: 0x35 + +binary_sensor: + - platform: tca8418 + keypad_id: matrix_keypad_device + row: 0 + col: 0 + name: "Key top left" + - platform: tca8418 + keypad_id: matrix_keypad_device + key: "5" + name: "Key five" + - platform: tca8418 + keypad_id: inputs_only_device + key_code: 97 + name: "Input ROW0" diff --git a/tests/components/tca8418/test.esp32-idf.yaml b/tests/components/tca8418/test.esp32-idf.yaml new file mode 100644 index 0000000000..0621ab995e --- /dev/null +++ b/tests/components/tca8418/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO13 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + tca8418: !include common.yaml diff --git a/tests/components/tca8418/test.esp8266-ard.yaml b/tests/components/tca8418/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9a9351cde8 --- /dev/null +++ b/tests/components/tca8418/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO12 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tca8418: !include common.yaml diff --git a/tests/components/text/common-triggers.yaml b/tests/components/text/common-triggers.yaml new file mode 100644 index 0000000000..585cb30c2d --- /dev/null +++ b/tests/components/text/common-triggers.yaml @@ -0,0 +1,11 @@ +# on_value with the new text as trigger argument. +text: + - platform: template + name: Text Triggers + id: text_triggers + optimistic: true + mode: text + on_value: + - logger.log: + format: "Text is %s" + args: ["x.c_str()"] diff --git a/tests/components/text/test-triggers.esp8266-ard.yaml b/tests/components/text/test-triggers.esp8266-ard.yaml new file mode 100644 index 0000000000..aecb27e602 --- /dev/null +++ b/tests/components/text/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + text: !include common.yaml + triggers: !include common-triggers.yaml diff --git a/tests/components/tinyusb_keyboard/common.yaml b/tests/components/tinyusb_keyboard/common.yaml new file mode 100644 index 0000000000..8982d61b4a --- /dev/null +++ b/tests/components/tinyusb_keyboard/common.yaml @@ -0,0 +1,43 @@ +tinyusb: + +tinyusb_keyboard: + id: kb + +binary_sensor: + - platform: template + id: kb_letter + on_press: + - tinyusb_keyboard.press: + id: kb + key: a + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_shift_digit + on_press: + - tinyusb_keyboard.press: + id: kb + key: "1" + modifiers: 0x02 # Left Shift + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_raw_code + on_press: + - tinyusb_keyboard.press: + id: kb + key: 0x28 # Enter + modifiers: !lambda return 0; + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_volume_up + on_press: + - tinyusb_keyboard.media_press: + id: kb + usage: 0xE9 # Volume Up + on_release: + - tinyusb_keyboard.media_release: kb diff --git a/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..763cc106fc --- /dev/null +++ b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + tinyusb_keyboard: !include common.yaml diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml new file mode 100644 index 0000000000..ff10c79edc --- /dev/null +++ b/tests/components/tmc2209/common.yaml @@ -0,0 +1,57 @@ +stepper: + - platform: tmc2209 + id: tmc2209_step_dir + uart_id: uart_bus + address: 0 + step_pin: ${step_pin} + dir_pin: ${dir_pin} + enn_pin: ${enn_pin} + rsense: 110 mOhm + vsense: false + ottrim: 1 + run_current: 800mA + hold_current: 400mA + microsteps: 16 + max_speed: 2000 steps/s + acceleration: 1000 steps/s^2 + deceleration: 1000 steps/s^2 + + - platform: tmc2209 + id: tmc2209_serial + uart_id: uart_bus + address: 1 + index_pin: ${index_pin} + clock_frequency: 12MHz + analog_current_scale: true + max_speed: 1000 steps/s + +esphome: + on_boot: + then: + - tmc2209.configure: + id: tmc2209_step_dir + inverse_direction: true + microsteps: 8 + interpolation: true + enable_spreadcycle: false + tpwm_threshold: 0 + - tmc2209.configure: + id: tmc2209_serial + microsteps: !lambda "return 32;" + - tmc2209.currents: + id: tmc2209_step_dir + run_current: 1A + hold_current: 0.5A + iholddelay: 4 + tpowerdown: 20 + standstill_mode: freewheeling + - tmc2209.currents: + id: tmc2209_serial + irun: 20 + ihold: !lambda "return 5;" + - tmc2209.enable: tmc2209_serial + - stepper.set_target: + id: tmc2209_serial + target: 1000 + - tmc2209.disable: + id: tmc2209_step_dir diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml new file mode 100644 index 0000000000..5634c18686 --- /dev/null +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml new file mode 100644 index 0000000000..3e4ea33f7a --- /dev/null +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml new file mode 100644 index 0000000000..722f8f201f --- /dev/null +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO2 + dir_pin: GPIO3 + enn_pin: GPIO6 + index_pin: GPIO7 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + tmc2209: !include common.yaml diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 4c9d6fd736..09d23e2925 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -7,6 +7,8 @@ esphome: - uart.write: id: uart_id data: [0x00, 0x20, 0x42] + - lambda: |- + id(uart_swapped).load_settings(false); uart: - id: uart_id @@ -23,6 +25,10 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_swapped + tx_pin: 15 + rx_pin: 13 + baud_rate: 9600 switch: - platform: uart diff --git a/tests/components/udp/common-ipv6.yaml b/tests/components/udp/common-ipv6.yaml new file mode 100644 index 0000000000..560d45bf11 --- /dev/null +++ b/tests/components/udp/common-ipv6.yaml @@ -0,0 +1,20 @@ +network: + enable_ipv6: true + +udp: + - id: my_udp_v6 + listen_address: ff12::cafe + addresses: + - "ff12::cafe" + - "2001:db8::1" + on_receive: + - udp.write: + id: my_udp_v6 + data: "pong" + # Default listen address: a dual-stack socket on IPv6 builds + - id: my_udp_dual_stack + port: 18512 + on_receive: + - logger.log: + format: "Received %zu bytes" + args: [data.size()] diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index f3a082fdd2..680e51df1c 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -3,30 +3,30 @@ wifi: password: password1 udp: - id: my_udp - listen_address: 239.0.60.53 - addresses: - - "239.0.60.53" - - "192.168.1.255" - - "10.0.0.255" - on_receive: - - logger.log: - format: "Received %zu bytes" - args: [data.size()] - - udp.write: - id: my_udp - data: "hello world" - - udp.write: - id: my_udp - data: !lambda |- - return std::vector{1,3,4,5,6}; - - udp.write: - id: my_udp - data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - - udp.write: - id: my_udp - data: !lambda |- - return std::vector(data.begin(), data.end()); + - id: my_udp + listen_address: 239.0.60.53 + addresses: + - "239.0.60.53" + - "192.168.1.255" + - "10.0.0.255" + on_receive: + - logger.log: + format: "Received %zu bytes" + args: [data.size()] + - udp.write: + id: my_udp + data: "hello world" + - udp.write: + id: my_udp + data: !lambda |- + return std::vector{1,3,4,5,6}; + - udp.write: + id: my_udp + data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] + - udp.write: + id: my_udp + data: !lambda |- + return std::vector(data.begin(), data.end()); number: - platform: template @@ -43,8 +43,12 @@ button: on_press: then: - udp.write: + id: my_udp data: [0x01, 0x02, 0x03] - udp.write: + id: my_udp data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; + - udp.write: + id: my_udp + data: !lambda |- + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/udp/test-ipv6.esp32-idf.yaml b/tests/components/udp/test-ipv6.esp32-idf.yaml new file mode 100644 index 0000000000..1ce3077e5f --- /dev/null +++ b/tests/components/udp/test-ipv6.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + udp: !include common.yaml + udp_ipv6: !include common-ipv6.yaml diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 825d86c19e..98b56cbf79 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -13,3 +13,8 @@ udp: id: my_udp data: !lambda |- return std::vector{1,3,4,5,6}; + # Short forms without id: valid while there is a single udp instance + - udp.write: + data: [0x01, 0x02, 0x03] + - udp.write: !lambda |- + return std::vector(data.begin(), data.end()); diff --git a/tests/components/udp/test.nrf52-adafruit.yaml b/tests/components/udp/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..c5a9072a1d --- /dev/null +++ b/tests/components/udp/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +packages: + udp_ipv6: !include common-ipv6.yaml + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/web_server/common-ap-fallback.yaml b/tests/components/web_server/common-ap-fallback.yaml new file mode 100644 index 0000000000..0a4e1bc42d --- /dev/null +++ b/tests/components/web_server/common-ap-fallback.yaml @@ -0,0 +1,18 @@ +# STA with AP fallback plus local: true opts the fallback into captive AP mode; exercises +# the runtime start (fallback branch in wifi loop) and end (on STA connect, and when the +# provisioning window closes) paths. +provisioning: + timeout: 1min + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/common-ap-mode.yaml b/tests/components/web_server/common-ap-mode.yaml new file mode 100644 index 0000000000..dc522e3178 --- /dev/null +++ b/tests/components/web_server/common-ap-mode.yaml @@ -0,0 +1,9 @@ +# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server +# runs its own captive portal (DNS server, unknown URLs redirect to the page). +wifi: + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/test-ap-fallback.esp32-idf.yaml b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..172fb7de71 --- /dev/null +++ b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-fallback.yaml diff --git a/tests/components/web_server/test-ap-mode.esp32-idf.yaml b/tests/components/web_server/test-ap-mode.esp32-idf.yaml new file mode 100644 index 0000000000..077a7da009 --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/components/web_server/test-ap-mode.esp8266-ard.yaml b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml new file mode 100644 index 0000000000..077a7da009 --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 0000000000..badd604322 --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # Compiles in the Hisense ARG model, which climate.py adds only when it is configured + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") + + manifest.to_code = to_code_testing diff --git a/tests/components/whirlpool/benchmark.yaml b/tests/components/whirlpool/benchmark.yaml new file mode 100644 index 0000000000..7f0bd4e07c --- /dev/null +++ b/tests/components/whirlpool/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The whirlpool, climate_ir and remote_transmitter sources come in through the +# dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/whirlpool/common-dg11j1.yaml b/tests/components/whirlpool/common-dg11j1.yaml new file mode 100644 index 0000000000..4c0280c9ff --- /dev/null +++ b/tests/components/whirlpool/common-dg11j1.yaml @@ -0,0 +1,5 @@ +climate: + - platform: whirlpool + name: Whirlpool DG11J1-91 Climate + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/components/whirlpool/common.yaml b/tests/components/whirlpool/common.yaml index 6d55db1f08..8551f78c26 100644 --- a/tests/components/whirlpool/common.yaml +++ b/tests/components/whirlpool/common.yaml @@ -2,3 +2,9 @@ climate: - platform: whirlpool name: Whirlpool Climate transmitter_id: xmitr + - platform: whirlpool + id: whirlpool_hisense_arg + name: Whirlpool Hisense ARG Climate + model: HISENSE_ARG + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml new file mode 100644 index 0000000000..7d6fa086ef --- /dev/null +++ b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + whirlpool: !include common-dg11j1.yaml diff --git a/tests/components/whirlpool/test.esp32-idf.yaml b/tests/components/whirlpool/test.esp32-idf.yaml index e891f9dc85..39e62c0664 100644 --- a/tests/components/whirlpool/test.esp32-idf.yaml +++ b/tests/components/whirlpool/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + whirlpool: !include common.yaml diff --git a/tests/components/whirlpool/test.esp8266-ard.yaml b/tests/components/whirlpool/test.esp8266-ard.yaml index 4bed2f03e5..6e2dfff6dd 100644 --- a/tests/components/whirlpool/test.esp8266-ard.yaml +++ b/tests/components/whirlpool/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + whirlpool: !include common.yaml diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp new file mode 100644 index 0000000000..ef628abe54 --- /dev/null +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/whirlpool/whirlpool.h" +#include "esphome/core/preferences.h" + +namespace esphome::whirlpool::testing { + +static constexpr size_t FRAME_LEN = 21; +using Frame = std::array; + +static constexpr int32_t HEADER_MARK = 9000; +static constexpr int32_t HEADER_SPACE = 4494; +static constexpr int32_t BIT_MARK = 572; +static constexpr int32_t ONE_SPACE = 1659; +static constexpr int32_t ZERO_SPACE = 553; +static constexpr int32_t GAP_SPACE = 7960; + +static bool divider_after(size_t bytes_sent) { return bytes_sent == 6 || bytes_sent == 14; } + +/// The waveform for a frame: header, bytes LSB first, a divider after bytes 6 and 14, final mark +static remote_base::RawTimings waveform(const Frame &bytes) { + remote_base::RawTimings raw; + raw.push_back(HEADER_MARK); + raw.push_back(-HEADER_SPACE); + for (size_t i = 0; i < FRAME_LEN; i++) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(BIT_MARK); + raw.push_back((bytes[i] >> bit) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + if (divider_after(i + 1)) { + raw.push_back(BIT_MARK); + raw.push_back(-GAP_SPACE); + } + } + raw.push_back(BIT_MARK); + return raw; +} + +/// Decodes a captured waveform back into bytes, checking header, dividers and footer positions +static ::testing::AssertionResult decode(const remote_base::RawTimings &raw, Frame &out) { + static constexpr size_t EXPECTED_LEN = 2 + FRAME_LEN * 16 + 2 * 2 + 1; + if (raw.size() != EXPECTED_LEN) + return ::testing::AssertionFailure() << "timing count " << raw.size() << ", expected " << EXPECTED_LEN; + if (raw[0] != HEADER_MARK || raw[1] != -HEADER_SPACE) + return ::testing::AssertionFailure() << "bad header " << raw[0] << " " << raw[1]; + size_t pos = 2; + for (size_t i = 0; i < FRAME_LEN; i++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + if (raw[pos] != BIT_MARK) + return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " mark " << raw[pos]; + if (raw[pos + 1] == -ONE_SPACE) { + value |= 1 << bit; + } else if (raw[pos + 1] != -ZERO_SPACE) { + return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " space " << raw[pos + 1]; + } + pos += 2; + } + out[i] = value; + if (divider_after(i + 1)) { + if (raw[pos] != BIT_MARK || raw[pos + 1] != -GAP_SPACE) + return ::testing::AssertionFailure() << "missing divider after byte " << i + 1; + pos += 2; + } + } + if (raw[pos] != BIT_MARK) + return ::testing::AssertionFailure() << "bad footer " << raw[pos]; + return ::testing::AssertionSuccess(); +} + +static uint8_t xor_range(const Frame &f, size_t from, size_t to) { + uint8_t x = 0; + for (size_t i = from; i < to; i++) + x ^= f[i]; + return x; +} + +static ::testing::AssertionResult checksums_ok(const Frame &f) { + if (xor_range(f, 2, 13) != f[13]) + return ::testing::AssertionFailure() << "byte 13 checksum"; + if (xor_range(f, 14, 20) != f[20]) + return ::testing::AssertionFailure() << "byte 20 checksum"; + return ::testing::AssertionSuccess(); +} + +/// Fills in both checksums of a frame +static Frame with_checksums(Frame f) { + f[13] = xor_range(f, 2, 13); + f[20] = xor_range(f, 14, 20); + return f; +} + +static std::string hex(const Frame &f) { + std::string out; + for (uint8_t b : f) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", b); + out += buf; + } + return out; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + WhirlpoolClimate climate; + + explicit Fixture(Model model) { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + this->climate.set_model(model); + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + optional swing = {}) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + if (swing.has_value()) { + call.set_swing_mode(*swing); + } + call.perform(); + } + + /// Decodes the last transmitted frame, failing the test if it is malformed or has bad checksums + Frame last_frame() const { + Frame f{}; + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return f; + EXPECT_TRUE(decode(this->transmitter.frames.back(), f)); + EXPECT_TRUE(checksums_ok(f)) << hex(f); + return f; + } +}; + +static void expect_frame(const Frame &actual, const Frame &expected) { + EXPECT_EQ(actual, expected) << "actual " << hex(actual) << "\nexpected " << hex(expected); +} + +// The receive path ignores frames for 500 ms after a transmit, so these run on a fixture that has not transmitted +static bool receive(WhirlpoolClimate &climate, const Frame &bytes) { + const remote_base::RawTimings raw = waveform(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + return static_cast(climate).on_receive(data); +} + +TEST(WhirlpoolDG11J13A, CoolFromOffMatchesKnownFrame) { + Fixture f(MODEL_DG11J1_3A); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + // byte 2: power toggle 0x04 | fan low 3; byte 3: (24 - 18) << 4 | cool 2; byte 15: cool overwrites toggle with 6 + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x62, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE5, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E}); +} + +TEST(WhirlpoolDG11J191, CoolFromOffUsesSixteenDegreeBase) { + Fixture f(MODEL_DG11J1_91); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + // byte 3: (24 - 16) << 4 | cool 2 + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E}); +} + +TEST(WhirlpoolHisenseArg, PowerToggleThenSettingsOnly) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF); + // byte 11 display on, byte 12 ambient 24 C, byte 15 power toggle + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x9D, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00, 0x09}); + + // Only the fan changed: no power bit, byte 15 settings only + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_MEDIUM); + expect_frame(f.last_frame(), {0x83, 0x06, 0x02, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x98, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x0A}); +} + +TEST(WhirlpoolHisenseArg, ModeSpecificFanAndTemperature) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + f.command(climate::CLIMATE_MODE_DRY, 24, climate::CLIMATE_FAN_HIGH); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x03, 0) << "dry forces fan auto"; + EXPECT_EQ(frame[3], 0x83); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_HIGH) << "the user's choice survives the dry frame"; + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_AUTO); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x03, 2) << "fan-only with fan auto sends medium"; + EXPECT_EQ(frame[3], 0x74) << "fixed temperature nibble 7, fan mode 4"; + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(f.last_frame()[2] & 0x03, 1); + + struct RangeCase { + float target; + uint8_t range; + }; + // 23 C (step 7) shares the catch-all code with the high end of the range + for (const RangeCase &c : + {RangeCase{16, 0x30}, RangeCase{22, 0x50}, RangeCase{23, 0x20}, RangeCase{24, 0x40}, RangeCase{30, 0x20}}) { + f.command(climate::CLIMATE_MODE_HEAT_COOL, c.target, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[3], 0x71) << "target " << c.target; + EXPECT_EQ(frame[2] & 0x70, c.range) << "target " << c.target; + EXPECT_EQ(frame[2] & 0x03, 3) << "target " << c.target; + EXPECT_EQ(frame[15], 0x02) << "target " << c.target; + } +} + +TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + // A later command that does not mention swing still carries it + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); + + f.command(climate::CLIMATE_MODE_COOL, 26, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); +} + +TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) { + Fixture f(MODEL_DG11J1_3A); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); +} + +// Cool 24 C (step 8), fan low, powered on assumed +static Frame hisense_cool_frame() { + return with_checksums({0x83, 0x06, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x00, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x00}); +} + +static void assume_on(WhirlpoolClimate &climate, climate::ClimateMode mode, float target) { + climate.mode = mode; + climate.target_temperature = target; + climate.powered_on_assumed = true; +} + +TEST(WhirlpoolHisenseArg, ReceiveSwingIsAbsolute) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + Frame frame = hisense_cool_frame(); + frame[8] = 0x40; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // The same frame again keeps vertical; the DG11J1 path would toggle back + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + frame[8] = 0x00; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(WhirlpoolHisenseArg, ReceiveTemperature) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + EXPECT_TRUE(receive(f.climate, hisense_cool_frame())); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + + // Heat-cool carries a fixed temperature nibble, which must not overwrite the target + Frame frame = hisense_cool_frame(); + frame[2] = 0x43; + frame[3] = 0x71; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + + // Fan-only likewise + frame[2] = 0x02; + frame[3] = 0x74; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); +} + +TEST(WhirlpoolHisenseArg, ReceivePowerBitTogglesAssumption) { + Fixture f(MODEL_HISENSE_ARG); + ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + ASSERT_FALSE(f.climate.powered_on_assumed); + + Frame frame = hisense_cool_frame(); + frame[2] |= 0x04; + frame[15] = 0x01; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_TRUE(f.climate.powered_on_assumed); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_FALSE(f.climate.powered_on_assumed); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(WhirlpoolHisenseArg, ReceiveRejectsBadChecksum) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + Frame frame = hisense_cool_frame(); + frame[13] ^= 0x01; + EXPECT_FALSE(receive(f.climate, frame)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); + + frame = hisense_cool_frame(); + frame[20] ^= 0x01; + EXPECT_FALSE(receive(f.climate, frame)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); +} + +TEST(WhirlpoolHisenseArg, ReceiveIgnoredRightAfterTransmit) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_LOW); + EXPECT_FALSE(receive(f.climate, hisense_cool_frame())); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); +} + +TEST(WhirlpoolHisenseArg, PowerOffKeepsTheCoolModeCode) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW); + const Frame frame = f.last_frame(); + // The hardware-tested remote sends the cool code with the power toggle, not the heat code 0 + EXPECT_EQ(frame[3] & 0x07, 2); + EXPECT_EQ(frame[2] & 0x04, 0x04); + EXPECT_EQ(frame[15], 0x01); +} + +TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_HEAT, 30, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + f.command(climate::CLIMATE_MODE_HEAT_COOL, 22, climate::CLIMATE_FAN_AUTO); + f.command(climate::CLIMATE_MODE_DRY, 18, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + f.command(climate::CLIMATE_MODE_FAN_ONLY, 25, climate::CLIMATE_FAN_LOW); + f.command(climate::CLIMATE_MODE_OFF, 25, climate::CLIMATE_FAN_LOW); + ASSERT_EQ(f.transmitter.frames.size(), 5u); + for (const auto &raw : f.transmitter.frames) { + Frame frame{}; + ASSERT_TRUE(decode(raw, frame)); + EXPECT_TRUE(checksums_ok(frame)) << hex(frame); + EXPECT_EQ(frame[0], 0x83); + EXPECT_EQ(frame[1], 0x06); + EXPECT_EQ(frame[11], 0x80); + EXPECT_EQ(frame[12], 0x18); + } +} + +} // namespace esphome::whirlpool::testing diff --git a/tests/components/wifi/test-lease-cache.esp32-idf.yaml b/tests/components/wifi/test-lease-cache.esp32-idf.yaml new file mode 100644 index 0000000000..47073b9106 --- /dev/null +++ b/tests/components/wifi/test-lease-cache.esp32-idf.yaml @@ -0,0 +1,8 @@ +# DHCP lease cache with the default RTC storage and max_age. +wifi: + lease_cache: + enabled: true + max_age: 12h + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml b/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml new file mode 100644 index 0000000000..da74ce50ae --- /dev/null +++ b/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml @@ -0,0 +1,13 @@ +# lease_cache with storage: flash, alongside manual_ip. +wifi: + lease_cache: + enabled: true + storage: flash + manual_ip: + static_ip: 192.168.1.100 + gateway: 192.168.1.1 + subnet: 255.255.255.0 + dns1: 1.1.1.1 + +packages: + wifi: !include common.yaml diff --git a/tests/integration/fixtures/branch_resume_async.yaml b/tests/integration/fixtures/branch_resume_async.yaml index fb09323809..76654ddaa9 100644 --- a/tests/integration/fixtures/branch_resume_async.yaml +++ b/tests/integration/fixtures/branch_resume_async.yaml @@ -72,6 +72,26 @@ script: format: "parallel-after-if-%d" args: ['run'] + - id: stop_in_later_while_check + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: |- + if (++id(counter) == 2) + id(stop_in_later_while_check).stop(); + return true; + then: + - lambda: 'ESP_LOGI("test", "later-check-body-%d", id(counter));' + - logger.log: "after-later-check-should-not-run" + - id: empty_while + then: + - while: + condition: + lambda: 'return true;' + then: [] + - logger.log: "after-empty-while" + - id: nested_if_last_in_while then: - lambda: 'id(counter) = 0;' @@ -206,6 +226,14 @@ api: ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0); - delay: 600ms - logger.log: "stop-check-done" + - action: run_stop_in_later_while_check + then: + - script.execute: stop_in_later_while_check + - delay: 200ms + - logger.log: "later-check-done" + - action: run_empty_while + then: + - script.execute: empty_while - action: run_nested_if_last_in_while then: - script.execute: nested_if_last_in_while diff --git a/tests/integration/fixtures/continuation_actions.yaml b/tests/integration/fixtures/continuation_actions.yaml index bdfe149cb7..d48569f0f4 100644 --- a/tests/integration/fixtures/continuation_actions.yaml +++ b/tests/integration/fixtures/continuation_actions.yaml @@ -5,7 +5,7 @@ host: api: actions: - # Test 1: IfAction with ContinuationAction (then/else branches) + # Test 1: IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -27,7 +27,7 @@ api: args: ['value'] - logger.log: "if completed" - # Test 2: Nested IfAction (multiple ContinuationAction instances) + # Test 2: Nested IfAction - action: test_nested_if variables: outer: bool @@ -51,7 +51,7 @@ api: - logger.log: "nested-outer-false" - logger.log: "nested if completed" - # Test 3: WhileAction with WhileLoopContinuation + # Test 3: WhileAction - action: test_while_action variables: max_count: int @@ -74,7 +74,7 @@ api: value: !lambda 'return id(continuation_test_counter) + 1;' - logger.log: "while completed" - # Test 4: RepeatAction with RepeatLoopContinuation + # Test 4: RepeatAction - action: test_repeat_action variables: count: int @@ -90,7 +90,7 @@ api: args: ['iteration'] - logger.log: "repeat completed" - # Test 5: Combined continuations (if + while + repeat) + # Test 5: Combined if + while + repeat - action: test_combined variables: do_loop: bool @@ -123,7 +123,7 @@ api: - logger.log: "combined-skipped" - logger.log: "combined completed" - # Test 6: Rapid triggers to verify memory efficiency + # Test 6: Rapid triggers - action: test_rapid_if then: - logger.log: "=== Rapid if test start ===" @@ -153,7 +153,7 @@ globals: restore_value: false initial_value: '0' -# Sensor to test rapid automation triggers with if/else (ContinuationAction) +# Sensor to test rapid automation triggers with if/else sensor: - platform: template id: rapid_sensor diff --git a/tests/integration/fixtures/lvgl_color_picker_render.yaml b/tests/integration/fixtures/lvgl_color_picker_render.yaml new file mode 100644 index 0000000000..320b757860 --- /dev/null +++ b/tests/integration/fixtures/lvgl_color_picker_render.yaml @@ -0,0 +1,82 @@ +esphome: + name: lvgl-color-picker-render + +host: + +api: + actions: + # A snapshot never writes over an existing file, so the test supplies a new name each time. + - action: take_screenshot + variables: + name: string + then: + - snapshot.take: + id: picker_display + filename: !lambda return name; + - action: set_orange + then: + - lvgl.color_picker.update: + id: picker_sliders + color: 0xFF8800 + - lvgl.color_picker.update: + id: picker_wheel + color: 0xFF8800 + # White has no saturation, which a hue-only picker could never change again + - lvgl.color_picker.update: + id: picker_hue + color: 0xFFFFFF + +logger: + level: DEBUG + +display: + - platform: snapshot + id: picker_display + auto_clear_enabled: false + dimensions: + width: 680 + height: 480 + +lvgl: + displays: picker_display + default_font: montserrat_14 + widgets: + - obj: + width: 100% + height: 100% + bg_color: 0x404040 + bg_opa: cover + border_width: 0 + radius: 0 + pad_all: 4 + layout: + type: flex + flex_flow: row_wrap + pad_row: 4 + pad_column: 4 + widgets: + - color_picker: + id: picker_sliders + width: 196 + color: 0x3366CC + - color_picker: + id: picker_wheel + width: 196 + wheel: true + color: 0x3366CC + # Sized from the text font + - color_picker: + id: picker_content + color: 0x3366CC + # No red, green and blue bars beside the swatch + - color_picker: + id: picker_hsv + width: 196 + sliders: hsv + color: 0x3366CC + # Only the hue bar + - color_picker: + id: picker_hue + width: 196 + sliders: hue + color: 0x3366CC diff --git a/tests/integration/fixtures/socket_wake_gate_udp.yaml b/tests/integration/fixtures/socket_wake_gate_udp.yaml new file mode 100644 index 0000000000..27d6df9094 --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_udp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-udp + on_boot: + priority: -100 + then: + - lambda: |- + // Without a loop-monitored socket a datagram waits for loop_interval_ to expire. + App.set_loop_interval(2000); + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + +logger: + level: INFO + +udp: + - id: test_udp + port: UDP_LISTEN_PORT_PLACEHOLDER + addresses: ["127.0.0.1"] + on_receive: + - logger.log: + format: "PONG" + level: INFO diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml new file mode 100644 index 0000000000..113e0881e6 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -0,0 +1,84 @@ +esphome: + name: uart-mock-ld2410s-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Output-mode switch to minimal, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Config-mode end, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + injections: + # Short frame, state 2 (target), distance 300 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, state 2, distance 300 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5] + # Short frame, state 1 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x01, 0x00, 0x00, 0x62] + # Noise byte directly followed by a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0xAA, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Partial standard header byte directly followed by a short frame, state 3 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0x6E, 0x03, 0x2C, 0x01, 0x62] + # Short frame with the impossible state 7, dropped, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x07, 0x00, 0x00, 0x62, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, with the impossible state 7, dropped, then a short frame, state 2 + # -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x07, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, 0x6E, + 0x02, 0x2C, 0x01, 0x62, + ] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml new file mode 100644 index 0000000000..5f06985c7b --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -0,0 +1,75 @@ +esphome: + name: uart-mock-ld2410s-link-lost + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Output-mode switch to minimal, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Config-mode end, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + injections: + # Short frames every second for 7 s, longer than the 5 s link timeout, so they must keep the link alive + # t=1 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=2 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=3 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=4 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=5 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=6 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=7 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Then silence: the link watchdog must start the init sequence over + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml new file mode 100644 index 0000000000..3502ccb85c --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml @@ -0,0 +1,100 @@ +esphome: + name: uart-mock-ld2410s-recovery + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No responses: the init commands go unanswered, so the test also covers the resend and give-up path; +# this does not affect receiving data frames +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + injections: + # Short frame, state 2 -> presence on (baseline) + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Standard header with a 255 byte length field (over the 96 byte buffer), three payload bytes, + # then a short frame, state 0 -> frame dropped, presence off + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame declaring 5 payload bytes but carrying 3, then a short frame, state 2: the short + # frame is read as the rest of the declared length, so the frame stays one byte short + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0: its first byte completes the truncated frame (footer mismatch); the + # rescan of the swallowed bytes rescues the state 2 short frame -> presence on, and leaves this + # frame's header pending, so the rest of it completes -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Command frame with zeros where the footer belongs, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0x00, + 0x00, 0x00, 0x62, + ] + # 100 bytes that never start a header, then a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: + [ + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x6E, 0x02, 0x2C, 0x01, 0x62, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame of the largest size that fits (86 byte payload: type 1, state 2, distance 300, + # 82 padding bytes) -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x56, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2460.yaml b/tests/integration/fixtures/uart_mock_ld2460.yaml new file mode 100644 index 0000000000..7c67392439 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2460.yaml @@ -0,0 +1,141 @@ +esphome: + name: uart-mock-ld2460-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2460's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # The hub queries the installation mode at boot (HLK-LD2460 serial protocol v1.0, table 11): + # FD FC FB FA 0A 0C 00 01 04 03 02 01 + # Answer with the side-mount receipt from table 12 (mode byte 01). + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01] + + injections: + # Phase 1 (t=100ms): the datasheet's own report example (table 2), one target at (1.5 m, 2.3 m) + # F4 F3 F2 F1 = header, 04 = function, 0F 00 = length 15 (little endian), + # 0F 00 = X 15 (0.1 m steps), 17 00 = Y 23, F8 F7 F6 F5 = footer + # Side mount: distance = sqrt(1.5² + 2.3²) = 2.746 m, angle = atan2(1.5, 2.3) = 33.1° + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0F, 0x00, + 0x0F, 0x00, 0x17, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 2 (t=300ms): garbage bytes accumulate in the parser buffer + - delay: 200ms + inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22] + + # Phase 3 (t=400ms): a report header with partial data and no footer + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x13, 0x00, 0x01, 0x02] + + # Phase 4 (t=600ms): overflow the 32-byte frame buffer. Phases 2 and 3 left 16 bytes + # in it; 48 more make 64, so the parser fills the buffer twice. The 33rd byte logs + # "Max frame length exceeded" and restarts the buffer, the 64th leaves it full, and the + # first byte of the next frame restarts it again, so phase 5 parses from a clean buffer. + - delay: 200ms + inject_rx: + [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ] + + # Phase 5 (t=700ms): recovery, two targets after the overflow + # Target 1 at (-2.0 m, 3.0 m): X = -20 = 0xFFEC, Y = 30 = 0x001E + # distance = 3.606 m, side-mount angle = atan2(-2.0, 3.0) = -33.7° + # Target 2 at (0.5 m, 1.0 m): X = 5, Y = 10 + # distance = 1.118 m, angle = atan2(0.5, 1.0) = 26.6° + # length = 11 + 2 * 4 = 19 = 0x13 + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x13, 0x00, + 0xEC, 0xFF, 0x1E, 0x00, + 0x05, 0x00, 0x0A, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 6 (t=900ms): the datasheet's top-mount receipt (table 12 example, + # FD FC FB FA 0A 0C 00 02 04 03 02 01) switches the angle formula, then the + # same single target as phase 1 reports again + # Top mount: angle = atan2(2.3, 1.5) = 56.9° + - delay: 200ms + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01] + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0F, 0x00, + 0x0F, 0x00, 0x17, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + +ld2460: + id: ld2460_dev + uart_id: mock_uart + +sensor: + - platform: ld2460 + ld2460_id: ld2460_dev + target_count: + name: "Target Count" + filters: &sensor_filters + - timeout: + timeout: 50ms + value: last + - throttle_with_priority: 50ms + target_1: + x: + name: "Target 1 X" + filters: *sensor_filters + y: + name: "Target 1 Y" + filters: *sensor_filters + distance: + name: "Target 1 Distance" + filters: *sensor_filters + angle: + name: "Target 1 Angle" + filters: *sensor_filters + target_2: + x: + name: "Target 2 X" + filters: *sensor_filters + y: + name: "Target 2 Y" + filters: *sensor_filters + distance: + name: "Target 2 Distance" + filters: *sensor_filters + angle: + name: "Target 2 Angle" + filters: *sensor_filters + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/udp_ipv6_unicast.yaml b/tests/integration/fixtures/udp_ipv6_unicast.yaml new file mode 100644 index 0000000000..32be96043c --- /dev/null +++ b/tests/integration/fixtures/udp_ipv6_unicast.yaml @@ -0,0 +1,24 @@ +esphome: + name: udp-ipv6-unicast-test + +host: + +api: + +network: + enable_ipv6: true + +logger: + level: DEBUG + +udp: + - id: test_udp + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_REPLY_PORT_PLACEHOLDER + addresses: + - "::1" + on_receive: + - udp.write: + id: test_udp + data: "PONG" diff --git a/tests/integration/fixtures/udp_multicast_join.yaml b/tests/integration/fixtures/udp_multicast_join.yaml new file mode 100644 index 0000000000..e2c6c350bf --- /dev/null +++ b/tests/integration/fixtures/udp_multicast_join.yaml @@ -0,0 +1,22 @@ +esphome: + name: udp-multicast-test + +host: + +api: + +logger: + level: DEBUG + +udp: + - id: test_udp + listen_address: 239.1.2.3 + addresses: + - "239.1.2.3" + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_LISTEN_PORT_PLACEHOLDER + on_receive: + - logger.log: + format: "Received UDP: %d bytes" + args: [data.size()] diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 060799862f..75bf0b4842 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,169 +1,180 @@ { - "tests/integration/test_action_concurrent_reentry.py": 12.49, - "tests/integration/test_addressable_light_transition.py": 17.27, - "tests/integration/test_alarm_control_panel_state_transitions.py": 15.05, - "tests/integration/test_api_action_metadata.py": 10.78, - "tests/integration/test_api_action_responses.py": 17.68, - "tests/integration/test_api_action_timeout.py": 17.48, - "tests/integration/test_api_conditional_memory.py": 11.82, - "tests/integration/test_api_custom_services.py": 8.17, - "tests/integration/test_api_decode_wire_types.py": 17.82, - "tests/integration/test_api_empty_message_roundtrip.py": 12.6, - "tests/integration/test_api_encode_boundaries.py": 14.83, - "tests/integration/test_api_get_time_response_timezone.py": 12.93, - "tests/integration/test_api_homeassistant.py": 19.79, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 12.45, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.67, - "tests/integration/test_api_list_entities_backpressure.py": 12.69, - "tests/integration/test_api_message_size_batching.py": 15.39, - "tests/integration/test_api_reboot_timeout.py": 12.25, - "tests/integration/test_api_string_lambda.py": 13.84, - "tests/integration/test_api_vv_logging.py": 12.98, - "tests/integration/test_api_zero_psk_provisioning.py": 19.36, - "tests/integration/test_areas_and_devices.py": 8.09, - "tests/integration/test_automation_wait_actions.py": 14.26, - "tests/integration/test_automations.py": 20.27, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.02, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.18, - "tests/integration/test_binary_sensor_invalidate_state.py": 13.04, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 10.44, - "tests/integration/test_build_info.py": 12.48, - "tests/integration/test_camera_mock.py": 12.53, - "tests/integration/test_climate_control_action.py": 9.7, - "tests/integration/test_climate_custom_modes.py": 14.68, - "tests/integration/test_continuation_actions.py": 12.16, - "tests/integration/test_cover_control_action.py": 11.76, - "tests/integration/test_crc8_helper.py": 8.21, - "tests/integration/test_device_id_in_state.py": 26.48, - "tests/integration/test_duplicate_entities.py": 14.89, - "tests/integration/test_entity_icon.py": 15.75, - "tests/integration/test_entity_missing_state.py": 9.8, - "tests/integration/test_fan_turn_on_action.py": 12.96, - "tests/integration/test_fnv1_hash_object_id.py": 2.55, - "tests/integration/test_fnv1a_hash.py": 9.37, - "tests/integration/test_gpio_expander_cache.py": 12.31, - "tests/integration/test_host_logger_thread_safety.py": 7.8, - "tests/integration/test_host_mode_basic.py": 11.59, - "tests/integration/test_host_mode_batch_delay.py": 8.18, - "tests/integration/test_host_mode_climate_basic_state.py": 12.53, - "tests/integration/test_host_mode_climate_control.py": 14.3, - "tests/integration/test_host_mode_empty_string_options.py": 13.74, - "tests/integration/test_host_mode_entity_fields.py": 15.51, - "tests/integration/test_host_mode_fan_preset.py": 9.63, - "tests/integration/test_host_mode_many_entities.py": 16.22, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 13.26, - "tests/integration/test_host_mode_noise_encryption.py": 29.86, - "tests/integration/test_host_mode_reconnect.py": 11.7, - "tests/integration/test_host_mode_sensor.py": 11.74, - "tests/integration/test_host_ota.py": 129.75, - "tests/integration/test_host_preferences.py": 8.46, - "tests/integration/test_host_preferences_suspend_resume.py": 14.3, - "tests/integration/test_improv_serial_uart.py": 15.6, - "tests/integration/test_large_message_batching.py": 13.6, - "tests/integration/test_legacy_area.py": 11.44, - "tests/integration/test_legacy_climate_compat.py": 12.99, - "tests/integration/test_legacy_fan_compat.py": 8.75, - "tests/integration/test_light_automations.py": 15.38, - "tests/integration/test_light_binary_effect_off_phase.py": 23.85, - "tests/integration/test_light_calls.py": 16.45, - "tests/integration/test_light_constant_brightness.py": 16.87, - "tests/integration/test_light_control_action.py": 16.25, - "tests/integration/test_light_dim_relative_action.py": 14.03, - "tests/integration/test_light_effect_zero_brightness.py": 16.53, - "tests/integration/test_light_initial_state.py": 15.16, - "tests/integration/test_light_repeat_effect.py": 22.35, - "tests/integration/test_light_restore_state.py": 11.68, - "tests/integration/test_light_resume_effect.py": 17.07, - "tests/integration/test_light_toggle_action.py": 14.35, - "tests/integration/test_light_transition_state_publish_interval.py": 47.18, - "tests/integration/test_lock_automations.py": 12.65, - "tests/integration/test_logger_buffered_recursion_guard.py": 12.2, - "tests/integration/test_loop_disable_enable.py": 11.31, - "tests/integration/test_loop_interval_decoupling.py": 13.71, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 11.78, - "tests/integration/test_lvgl_headless_render.py": 49.4, - "tests/integration/test_micros_to_millis.py": 6.71, - "tests/integration/test_multi_click_trigger.py": 10.38, - "tests/integration/test_multi_device_preferences.py": 16.06, - "tests/integration/test_noise_encryption_key_protection.py": 13.43, - "tests/integration/test_object_id_api_verification.py": 14.32, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.57, - "tests/integration/test_object_id_no_friendly_name.py": 15.59, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 24.62, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 29.88, - "tests/integration/test_online_image_bmp.py": 23.67, - "tests/integration/test_oversized_payloads.py": 51.21, - "tests/integration/test_preference_key_stability.py": 14.85, - "tests/integration/test_runtime_stats.py": 11.2, - "tests/integration/test_safe_mode_loop_runs.py": 8.22, - "tests/integration/test_scheduler_blocking_warning.py": 28.18, - "tests/integration/test_scheduler_bulk_cleanup.py": 12.49, - "tests/integration/test_scheduler_defer_cancel.py": 9.95, - "tests/integration/test_scheduler_defer_cancel_regular.py": 13.32, - "tests/integration/test_scheduler_defer_fifo_simple.py": 13.18, - "tests/integration/test_scheduler_defer_stress.py": 14.29, - "tests/integration/test_scheduler_heap_stress.py": 4.73, - "tests/integration/test_scheduler_internal_id_no_collision.py": 14.05, - "tests/integration/test_scheduler_interval_reschedule.py": 9.0, - "tests/integration/test_scheduler_interval_zero_coerced.py": 3.27, - "tests/integration/test_scheduler_null_name.py": 12.94, - "tests/integration/test_scheduler_numeric_id_test.py": 11.83, - "tests/integration/test_scheduler_pool.py": 9.66, - "tests/integration/test_scheduler_rapid_cancellation.py": 3.95, - "tests/integration/test_scheduler_recursive_timeout.py": 12.91, - "tests/integration/test_scheduler_removed_item_race.py": 11.9, - "tests/integration/test_scheduler_self_keyed.py": 13.42, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 8.01, - "tests/integration/test_scheduler_string_test.py": 10.2, - "tests/integration/test_script_array_params.py": 12.99, - "tests/integration/test_script_delay_params.py": 14.73, - "tests/integration/test_script_queued.py": 16.62, - "tests/integration/test_script_queued_idle_loop.py": 11.13, - "tests/integration/test_script_wait_on_boot.py": 13.31, - "tests/integration/test_sdl_headless_screenshot.py": 14.76, - "tests/integration/test_select_stringref_trigger.py": 10.98, - "tests/integration/test_sensor_filters_delta.py": 12.63, - "tests/integration/test_sensor_filters_ring_buffer.py": 11.13, - "tests/integration/test_sensor_filters_sliding_window.py": 40.2, - "tests/integration/test_sensor_filters_value_list.py": 10.25, - "tests/integration/test_sensor_raw_state.py": 26.04, - "tests/integration/test_sensor_timeout_filter.py": 13.81, - "tests/integration/test_set_internal_at_boot.py": 14.13, - "tests/integration/test_snapshot_display.py": 16.36, - "tests/integration/test_socket_ipv4_resolve.py": 11.75, - "tests/integration/test_socket_tcp_client_link.py": 9.79, - "tests/integration/test_socket_wake_gate_tcp.py": 15.29, - "tests/integration/test_status_flags.py": 20.87, - "tests/integration/test_strftime_to.py": 10.06, - "tests/integration/test_syslog.py": 18.51, - "tests/integration/test_tcp_uart_bridge.py": 14.71, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 8.4, - "tests/integration/test_template_climate_basic.py": 14.22, - "tests/integration/test_template_climate_custom_modes.py": 11.61, - "tests/integration/test_template_climate_nonoptimistic.py": 13.47, - "tests/integration/test_template_climate_on_control_ordering.py": 13.99, - "tests/integration/test_template_climate_publish_all_fields.py": 10.98, - "tests/integration/test_template_climate_sensor_push.py": 14.86, - "tests/integration/test_template_climate_set_actions.py": 11.79, - "tests/integration/test_template_climate_two_point_temperature.py": 14.92, - "tests/integration/test_template_text_save.py": 13.35, - "tests/integration/test_template_time.py": 10.07, - "tests/integration/test_text_command.py": 12.11, - "tests/integration/test_text_sensor_raw_state.py": 13.96, - "tests/integration/test_uart_mock_ld2410.py": 30.03, - "tests/integration/test_uart_mock_ld2412.py": 35.57, - "tests/integration/test_uart_mock_ld2420.py": 26.6, - "tests/integration/test_uart_mock_ld2450.py": 13.03, - "tests/integration/test_uart_mock_modbus.py": 309.49, - "tests/integration/test_uart_tcp_bridge.py": 12.67, - "tests/integration/test_udp.py": 11.64, - "tests/integration/test_use_address_runtime.py": 16.79, - "tests/integration/test_valve_control_action.py": 12.76, - "tests/integration/test_varint_five_byte_device_id.py": 14.05, - "tests/integration/test_wait_until_mid_loop_timing.py": 12.48, - "tests/integration/test_wait_until_on_boot.py": 12.54, - "tests/integration/test_wait_until_ordering.py": 13.32, - "tests/integration/test_wait_until_reentrant_restart.py": 5.89, - "tests/integration/test_wake_loop_forces_phase_b.py": 13.8, - "tests/integration/test_water_heater_template.py": 24.77 + "tests/integration/test_action_concurrent_reentry.py": 14.33, + "tests/integration/test_addressable_light_transition.py": 18.53, + "tests/integration/test_alarm_control_panel_state_transitions.py": 15.5, + "tests/integration/test_api_action_metadata.py": 11.08, + "tests/integration/test_api_action_responses.py": 15.46, + "tests/integration/test_api_action_timeout.py": 16.54, + "tests/integration/test_api_conditional_memory.py": 11.84, + "tests/integration/test_api_custom_services.py": 13.37, + "tests/integration/test_api_decode_wire_types.py": 17.49, + "tests/integration/test_api_empty_message_roundtrip.py": 12.35, + "tests/integration/test_api_encode_boundaries.py": 10.93, + "tests/integration/test_api_get_time_response_timezone.py": 13.5, + "tests/integration/test_api_homeassistant.py": 18.7, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76, + "tests/integration/test_api_homeassistant_entities.py": 15.12, + "tests/integration/test_api_list_entities_backpressure.py": 14.2, + "tests/integration/test_api_message_size_batching.py": 13.72, + "tests/integration/test_api_outgoing_connection.py": 28.66, + "tests/integration/test_api_reboot_timeout.py": 9.01, + "tests/integration/test_api_string_lambda.py": 12.16, + "tests/integration/test_api_vv_logging.py": 10.14, + "tests/integration/test_api_zero_psk_provisioning.py": 18.26, + "tests/integration/test_areas_and_devices.py": 10.34, + "tests/integration/test_automation_wait_actions.py": 9.93, + "tests/integration/test_automations.py": 20.02, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03, + "tests/integration/test_binary_sensor_invalidate_state.py": 8.43, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39, + "tests/integration/test_branch_resume_async.py": 16.64, + "tests/integration/test_build_info.py": 12.41, + "tests/integration/test_camera_mock.py": 13.29, + "tests/integration/test_climate_control_action.py": 15.18, + "tests/integration/test_climate_custom_modes.py": 14.12, + "tests/integration/test_continuation_actions.py": 14.06, + "tests/integration/test_cover_control_action.py": 9.59, + "tests/integration/test_crc8_helper.py": 11.51, + "tests/integration/test_device_id_in_state.py": 18.41, + "tests/integration/test_duplicate_entities.py": 14.95, + "tests/integration/test_entity_icon.py": 15.57, + "tests/integration/test_entity_missing_state.py": 13.91, + "tests/integration/test_fan_turn_on_action.py": 13.85, + "tests/integration/test_fnv1_hash_object_id.py": 10.12, + "tests/integration/test_fnv1a_hash.py": 9.95, + "tests/integration/test_gpio_expander_cache.py": 4.43, + "tests/integration/test_host_logger_thread_safety.py": 12.54, + "tests/integration/test_host_mode_basic.py": 12.66, + "tests/integration/test_host_mode_batch_delay.py": 13.34, + "tests/integration/test_host_mode_climate_basic_state.py": 15.19, + "tests/integration/test_host_mode_climate_control.py": 15.35, + "tests/integration/test_host_mode_empty_string_options.py": 13.04, + "tests/integration/test_host_mode_entity_fields.py": 10.42, + "tests/integration/test_host_mode_fan_preset.py": 11.7, + "tests/integration/test_host_mode_many_entities.py": 16.79, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62, + "tests/integration/test_host_mode_noise_encryption.py": 21.06, + "tests/integration/test_host_mode_reconnect.py": 11.29, + "tests/integration/test_host_mode_sensor.py": 12.25, + "tests/integration/test_host_ota.py": 109.97, + "tests/integration/test_host_preferences.py": 12.92, + "tests/integration/test_host_preferences_suspend_resume.py": 12.3, + "tests/integration/test_host_write_after_peer_close.py": 14.42, + "tests/integration/test_improv_serial_uart.py": 16.29, + "tests/integration/test_ir_rf_transmit_complete.py": 10.73, + "tests/integration/test_large_message_batching.py": 13.94, + "tests/integration/test_legacy_area.py": 13.34, + "tests/integration/test_legacy_climate_compat.py": 13.19, + "tests/integration/test_legacy_fan_compat.py": 9.31, + "tests/integration/test_light_automations.py": 14.39, + "tests/integration/test_light_binary_effect_off_phase.py": 14.75, + "tests/integration/test_light_calls.py": 13.85, + "tests/integration/test_light_constant_brightness.py": 12.22, + "tests/integration/test_light_control_action.py": 15.46, + "tests/integration/test_light_dim_relative_action.py": 15.3, + "tests/integration/test_light_effect_zero_brightness.py": 16.75, + "tests/integration/test_light_initial_state.py": 9.8, + "tests/integration/test_light_repeat_effect.py": 22.27, + "tests/integration/test_light_restore_state.py": 15.67, + "tests/integration/test_light_resume_effect.py": 17.79, + "tests/integration/test_light_toggle_action.py": 15.22, + "tests/integration/test_light_transition_state_publish_interval.py": 44.96, + "tests/integration/test_lock_automations.py": 12.55, + "tests/integration/test_logger_buffered_recursion_guard.py": 11.78, + "tests/integration/test_loop_disable_enable.py": 13.19, + "tests/integration/test_loop_interval_decoupling.py": 15.14, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91, + "tests/integration/test_lvgl_color_picker_render.py": 91.0, + "tests/integration/test_lvgl_headless_render.py": 46.26, + "tests/integration/test_micros_to_millis.py": 9.14, + "tests/integration/test_multi_click_trigger.py": 13.62, + "tests/integration/test_multi_device_preferences.py": 15.18, + "tests/integration/test_noise_encryption_key_protection.py": 21.14, + "tests/integration/test_object_id_api_verification.py": 9.54, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78, + "tests/integration/test_object_id_no_friendly_name.py": 24.2, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9, + "tests/integration/test_online_image_bmp.py": 24.53, + "tests/integration/test_oversized_payloads.py": 43.03, + "tests/integration/test_preference_key_stability.py": 14.22, + "tests/integration/test_runtime_stats.py": 15.85, + "tests/integration/test_safe_mode_loop_runs.py": 8.89, + "tests/integration/test_scheduler_background_wake.py": 17.82, + "tests/integration/test_scheduler_blocking_warning.py": 30.27, + "tests/integration/test_scheduler_bulk_cleanup.py": 8.78, + "tests/integration/test_scheduler_defer_cancel.py": 12.9, + "tests/integration/test_scheduler_defer_cancel_regular.py": 12.79, + "tests/integration/test_scheduler_defer_fifo_simple.py": 13.14, + "tests/integration/test_scheduler_defer_stress.py": 13.2, + "tests/integration/test_scheduler_heap_stress.py": 9.15, + "tests/integration/test_scheduler_internal_id_no_collision.py": 14.81, + "tests/integration/test_scheduler_interval_reschedule.py": 13.07, + "tests/integration/test_scheduler_interval_zero_coerced.py": 12.32, + "tests/integration/test_scheduler_null_name.py": 3.81, + "tests/integration/test_scheduler_numeric_id_test.py": 9.77, + "tests/integration/test_scheduler_pool.py": 10.16, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.7, + "tests/integration/test_scheduler_recursive_timeout.py": 13.72, + "tests/integration/test_scheduler_removed_item_race.py": 12.73, + "tests/integration/test_scheduler_self_keyed.py": 13.03, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88, + "tests/integration/test_scheduler_string_test.py": 14.1, + "tests/integration/test_script_array_params.py": 11.14, + "tests/integration/test_script_delay_params.py": 13.84, + "tests/integration/test_script_queued.py": 15.45, + "tests/integration/test_script_queued_idle_loop.py": 13.15, + "tests/integration/test_script_wait_on_boot.py": 12.64, + "tests/integration/test_sdl_headless_screenshot.py": 15.19, + "tests/integration/test_select_stringref_trigger.py": 13.51, + "tests/integration/test_sensor_filters_delta.py": 13.6, + "tests/integration/test_sensor_filters_ring_buffer.py": 9.22, + "tests/integration/test_sensor_filters_sliding_window.py": 40.87, + "tests/integration/test_sensor_filters_value_list.py": 15.43, + "tests/integration/test_sensor_raw_state.py": 25.12, + "tests/integration/test_sensor_timeout_filter.py": 9.64, + "tests/integration/test_sensor_timeout_throttle_filter.py": 14.63, + "tests/integration/test_set_internal_at_boot.py": 13.18, + "tests/integration/test_snapshot_display.py": 16.01, + "tests/integration/test_socket_ipv4_resolve.py": 11.3, + "tests/integration/test_socket_tcp_client_link.py": 13.88, + "tests/integration/test_socket_wake_gate_tcp.py": 14.28, + "tests/integration/test_status_flags.py": 20.55, + "tests/integration/test_status_momentary.py": 12.63, + "tests/integration/test_strftime_to.py": 9.47, + "tests/integration/test_syslog.py": 17.73, + "tests/integration/test_tcp_uart_bridge.py": 16.27, + "tests/integration/test_tcp_uart_server.py": 13.64, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42, + "tests/integration/test_template_climate_basic.py": 14.42, + "tests/integration/test_template_climate_custom_modes.py": 14.3, + "tests/integration/test_template_climate_nonoptimistic.py": 10.11, + "tests/integration/test_template_climate_on_control_ordering.py": 4.57, + "tests/integration/test_template_climate_publish_all_fields.py": 12.56, + "tests/integration/test_template_climate_sensor_push.py": 14.89, + "tests/integration/test_template_climate_set_actions.py": 14.12, + "tests/integration/test_template_climate_two_point_temperature.py": 9.08, + "tests/integration/test_template_text_save.py": 13.96, + "tests/integration/test_template_time.py": 12.73, + "tests/integration/test_text_command.py": 13.09, + "tests/integration/test_text_sensor_raw_state.py": 14.3, + "tests/integration/test_uart_mock_ld2410.py": 33.11, + "tests/integration/test_uart_mock_ld2412.py": 61.26, + "tests/integration/test_uart_mock_ld2420.py": 23.39, + "tests/integration/test_uart_mock_ld2450.py": 11.79, + "tests/integration/test_uart_mock_modbus.py": 323.58, + "tests/integration/test_uart_mock_ufm01.py": 71.03, + "tests/integration/test_uart_tcp_bridge.py": 13.3, + "tests/integration/test_udp.py": 7.04, + "tests/integration/test_use_address_runtime.py": 12.4, + "tests/integration/test_valve_control_action.py": 9.25, + "tests/integration/test_varint_five_byte_device_id.py": 9.34, + "tests/integration/test_wait_until_mid_loop_timing.py": 13.21, + "tests/integration/test_wait_until_on_boot.py": 12.66, + "tests/integration/test_wait_until_ordering.py": 9.06, + "tests/integration/test_wait_until_reentrant_restart.py": 10.83, + "tests/integration/test_wake_loop_forces_phase_b.py": 14.23, + "tests/integration/test_water_heater_template.py": 21.96 } diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py index 78064cbe30..f6fb5dbd45 100644 --- a/tests/integration/test_branch_resume_async.py +++ b/tests/integration/test_branch_resume_async.py @@ -73,6 +73,20 @@ async def test_branch_resume_async( assert count("stopped-branch-should-not-run") == 0 assert count("stopped-after-if-should-not-run") == 0 + # A while condition that stops the script on a later pass must not start the body again + done = wait_for("later-check-done") + await client.execute_service(service["run_stop_in_later_while_check"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("later-check-body-1") == 1 + assert count("later-check-body-2") == 0 + assert count("after-later-check-should-not-run") == 0 + + # An empty loop body cannot resume the loop, so the while finishes at once + done = wait_for("after-empty-while") + await client.execute_service(service["run_empty_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("after-empty-while") == 1 + done = wait_for("after-nested-while") await client.execute_service(service["run_nested_if_last_in_while"], {}) await asyncio.wait_for(done, timeout=2.0) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py index e6020c711a..1c6e8891a9 100644 --- a/tests/integration/test_continuation_actions.py +++ b/tests/integration/test_continuation_actions.py @@ -1,4 +1,4 @@ -"""Test continuation actions (ContinuationAction, WhileLoopContinuation, RepeatLoopContinuation).""" +"""Test that if, while and repeat resume the actions after them once their branch finishes.""" from __future__ import annotations @@ -17,10 +17,7 @@ async def test_continuation_actions( api_client_connected: APIClientConnectedFactory, ) -> None: """ - Test that continuation actions work correctly for if/while/repeat. - - These continuation classes replace LambdaAction with simple parent pointers, - saving 32-36 bytes per instance and eliminating std::function overhead. + Test that if/while/repeat resume the actions after them once their branch finishes. """ loop = asyncio.get_running_loop() @@ -218,7 +215,7 @@ async def test_continuation_actions( ) assert test_results["combined_complete"], "Combined did not complete" - # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) + # Test 6: Rapid triggers through an if/else test_service = next((s for s in services if s.name == "test_rapid_if"), None) assert test_service is not None, "test_rapid_if service not found" await client.execute_service(test_service, {}) diff --git a/tests/integration/test_lvgl_color_picker_render.py b/tests/integration/test_lvgl_color_picker_render.py new file mode 100644 index 0000000000..0204f29312 --- /dev/null +++ b/tests/integration/test_lvgl_color_picker_render.py @@ -0,0 +1,105 @@ +"""Golden-image test for the LVGL colour picker, drawn on a display with no screen. + +Pickers are drawn with every slider, with a colour wheel, at the size taken from the font, +and with only some of the sliders. The first two are then given a new colour by +`lvgl.color_picker.update`, and the hue-only one is given white, and they are drawn again. Each picture is compared against a hash, as in +test_lvgl_headless_render.py. If a change alters the output, the test fails, keeps the image it +saw and prints its hash; update the hash once the image has been looked at and found to be +correct. +""" + +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path + +import pytest + +from .artifact_utils import keep_artifact +from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp +from .types import APIClientConnectedFactory, RunCompiledFunction + +WIDTH = 680 +HEIGHT = 480 + +# Every picker showing 0x3366CC. +EXPECTED_INITIAL_SHA256 = ( + "5d93f4bd98fcbe418ef8d34572e3b0912e0421cfd87e61e8cc3c49778a0e0e47" +) +# The first two pickers showing 0xFF8800, and the hue-only one red rather than stuck at white, +# after the update action. +EXPECTED_UPDATED_SHA256 = ( + "8300a6d95f38e60ea3d175aa413e71b99de6ac7ad78c9adf5248bfb350cb35d3" +) +# The LVGL version the hashes were generated against; a version change can move them. +EXPECTED_LVGL_VERSION = "9.6.0" + +REDRAW_TIMEOUT = 5.0 + + +def _check(image: Bmp, capture: Path, expected: str, name: str) -> None: + assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + digest = hashlib.sha256(image.pixels).hexdigest() + if digest == expected: + return + kept = keep_artifact(f"lvgl_color_picker_{name}_actual.bmp", capture.read_bytes()) + + from esphome.components.lvgl import LVGL_VERSION + + version_hint = "" + if LVGL_VERSION != EXPECTED_LVGL_VERSION: + version_hint = ( + f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> " + f"{LVGL_VERSION}), which is the likely cause\n" + ) + pytest.fail( + f"{name} screen does not match the expected hash\n" + f"{version_hint}" + f" expected: {expected}\n" + f" actual: {digest}\n" + f"the image that was rendered has been kept at {kept}" + ) + + +@pytest.mark.asyncio +async def test_lvgl_color_picker_render( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The colour picker draws as expected, before and after its colour is changed.""" + snapshot_dir = tmp_path / "snapshots" + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + + async with run_compiled(yaml_config), api_client_connected() as client: + _, services = await client.list_entities_services() + take_screenshot = next(s for s in services if s.name == "take_screenshot") + set_orange = next(s for s in services if s.name == "set_orange") + + async def take(name: str) -> None: + await client.execute_service(take_screenshot, {"name": name}) + + image, capture = await capture_when_drawn(take, snapshot_dir, prefix="initial") + _check(image, capture, EXPECTED_INITIAL_SHA256, "initial") + + await client.execute_service(set_orange, {}) + # The redraw follows the update on a later LVGL tick, so capture until the picture changes. + loop = asyncio.get_running_loop() + deadline = loop.time() + REDRAW_TIMEOUT + attempt = 0 + while True: + attempt += 1 + capture = snapshot_dir / f"updated-{attempt}.bmp" + await take(capture.name) + updated = await wait_for_bmp(capture) + if updated.pixels != image.pixels: + break + if loop.time() >= deadline: + pytest.fail( + f"the screen did not change within {REDRAW_TIMEOUT}s of the update" + ) + await asyncio.sleep(0.2) + _check(updated, capture, EXPECTED_UPDATED_SHA256, "updated") diff --git a/tests/integration/test_socket_wake_gate_udp.py b/tests/integration/test_socket_wake_gate_udp.py new file mode 100644 index 0000000000..4edd8f14c2 --- /dev/null +++ b/tests/integration/test_socket_wake_gate_udp.py @@ -0,0 +1,64 @@ +"""Test that an incoming UDP datagram wakes the main loop at once.""" + +from __future__ import annotations + +import asyncio +import socket +import time + +import pytest + +from .types import RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_udp( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """A datagram must be handled well before loop_interval_ (2000ms) expires.""" + # Hold the port with SO_REUSEADDR so nothing else claims it while the firmware builds. The + # device's listen socket sets SO_REUSEADDR too and binds the wildcard address alongside it. + hold_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + hold_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + hold_sock.bind(("127.0.0.1", 0)) + listen_port = hold_sock.getsockname()[1] + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + try: + async with run_compiled(config, line_callback=on_log_line): + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared") + + # Datagrams go to the more specific bind, so release the hold before sending + hold_sock.close() + + # No API client is connected, so this datagram is the only thing that can wake select() + t_send = time.monotonic() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as send_sock: + send_sock.sendto(b"PING", ("127.0.0.1", listen_port)) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the wake the receive waits for loop_interval_; 500ms leaves CI headroom + assert elapsed_ms < 500, ( + f"UDP receive took {elapsed_ms:.0f}ms with loop_interval_=2000ms" + ) + finally: + hold_sock.close() diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py new file mode 100644 index 0000000000..8cd9b4b8dd --- /dev/null +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -0,0 +1,329 @@ +"""Integration tests for the LD2410S component with mock UART. + +test_uart_mock_ld2410s: + 1. Init sequence - config-mode start, output-mode switch and config-mode end go + out in order, each after the previous one is acknowledged, then the + component logs "Setup done" and clears its warning + 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard + data frame + 3. Resync - a noise byte or a partial header directly before a short frame + does not hide that frame + 4. A short frame and a standard frame whose state byte is above 3 are + dropped without publishing, and the short frame after each still publishes + +test_uart_mock_ld2410s_recovery (the mock never answers the init commands): + 1. Config-mode start is resent after the acknowledgement timeout + 2. Once the retries run out, config-mode end is sent and the component warns + that it is starting over + 3. A standard frame whose length field exceeds the receive buffer is dropped + 4. A truncated standard frame swallows the bytes after it up to its declared + length and fails the footer check; the swallowed bytes are rescanned, so + the short frames hidden in them still publish + 5. A command frame with a bad footer is dropped + 6. 100 bytes that never start a header are skipped + 7. A standard frame of the largest size that fits (86 byte payload) is accepted + +test_uart_mock_ld2410s_link_lost: + 1. Short frames arriving for longer than the 5 s link timeout keep the link up + 2. 5 s of silence logs "No data for 5 s (0 frames dropped), starting over", sets the warning flag + and sends a new config-mode start +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" +CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + +# has_target after each injection: short 2, short 0, standard frame state 2, +# short 1, noise + short 2, short 0, partial header + short 3, +# short 7 (dropped) + short 0, standard frame state 7 (dropped) + short 2 +EXPECTED_PRESENCE = [True, False, True, False, True, False, True, False, True] +# The short frame and the standard frame with state 7 are logged as dropped +EXPECTED_DROPPED_STATES = ["7", "7"] + +# has_target after each recovery phase (see uart_mock_ld2410s_recovery.yaml). +# The truncated frame swallows the state 2 short frame after it plus the header +# byte of the following state 0 short frame; the footer mismatch rescans those +# bytes, which publishes the swallowed frame and resumes the next one. +EXPECTED_RECOVERY_PRESENCE = [ + True, # baseline short frame + False, # short frame after the dropped oversized length field + True, # rescued: short frame swallowed by the truncated standard frame + False, # short frame whose header byte completed the truncated frame + True, # short frame + False, # short frame after the command frame with a bad footer + True, # short frame after 100 junk bytes + False, # short frame + True, # maximum-size standard frame + False, # short frame +] +# The truncated frame takes the next 6 bytes to reach its declared length +TRUNCATED_FRAME_HEX = "F4 F3 F2 F1 05 00 01 02 2C 6E 02 2C 01 62 6E" +BAD_COMMAND_FRAME_HEX = "FD FC FB FA 04 00 FE 01 00 00 00 00 00 00" + + +def _tx_frame(line: str) -> str | None: + """Return the hex payload of a uart_mock TX log line, or None.""" + if "uart_mock" not in line or "TX " not in line or "bytes: " not in line: + return None + return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip() + + +async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None: + """Subscribe the collector past the initial states, then start the mock scenario.""" + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(collector.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + +async def _wait_for_presence( + collector: SensorStateCollector, waiter: asyncio.Future[bool] +) -> None: + try: + await asyncio.wait_for(waiter, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the LD2410S init sequence, presence parsing and receiver resync.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + setup_done = loop.create_future() + warning_cleared = loop.create_future() + dropped_states: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): + warning_cleared.set_result(True) + if "Dropping frame with impossible state " in line: + dropped_states.append( + ANSI_ESCAPE.sub("", line.split("state ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(asyncio.gather(setup_done, warning_cleared), 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + assert collector.binary_states["has_target"] == EXPECTED_PRESENCE + assert dropped_states == EXPECTED_DROPPED_STATES + # Data frames must not restart the init sequence + assert len(tx_frames) == 3, tx_frames + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the unanswered-command retry, and that bad frames do not stop later ones.""" + loop = asyncio.get_running_loop() + tx_frames: list[tuple[float, str]] = [] + resent = loop.create_future() + gave_up = loop.create_future() + oversized_dropped = loop.create_future() + footer_mismatches: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append((loop.time(), frame)) + if "No acknowledgement for command 00FF, resending" in line and not ( + resent.done() + ): + resent.set_result(True) + if ( + "No acknowledgement for command 00FF, starting over in 15 s" in line + and not gave_up.done() + ): + gave_up.set_result(True) + if ( + "Dropping frame with a 255 byte payload, larger than the receive buffer" + in line + and not oversized_dropped.done() + ): + oversized_dropped.set_result(True) + if "Footer does not match header: " in line: + footer_mismatches.append( + ANSI_ESCAPE.sub("", line.split("header: ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) + >= len(EXPECTED_RECOVERY_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + try: + await asyncio.wait_for(asyncio.gather(resent, gave_up), 5.0) + except TimeoutError: + pytest.fail(f"No resend or give-up warning. TX frames: {tx_frames}") + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + # Recovery shows in the published states alone; the footer log below is + # only a cross-check that needs the fixture's VERBOSE logger level + assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE + assert oversized_dropped.done(), "Oversized length field was not dropped" + assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX] + + frames = [frame for _, frame in tx_frames] + # First send plus INIT_MAX_TIMEOUTS (2) resends, then config-mode end; + # the restart after the 15 s pause is out of the test's time window + assert frames == [CONFIG_START_TX] * 3 + [CONFIG_END_TX] + # Resends follow the 300 ms acknowledgement timeout + gaps = [b[0] - a[0] for a, b in zip(tx_frames, tx_frames[1:], strict=False)] + assert all(0.2 <= gap <= 1.0 for gap in gaps), gaps + + +# has_target after each short frame in uart_mock_ld2410s_link_lost.yaml +EXPECTED_LINK_PRESENCE = [True, False, True, False, True, False, True] +LINK_TIMEOUT_S = 5.0 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_link_lost( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that short frames keep the link alive and silence restarts the init.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + rx_injected_at: list[float] = [] + setup_done = loop.create_future() + link_lost = loop.create_future() + warning_set_after_loss = loop.create_future() + restarted = loop.create_future() + config_start_before_loss = 0 + + def line_callback(line: str) -> None: + nonlocal config_start_before_loss + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if frame == CONFIG_START_TX and link_lost.done() and not restarted.done(): + restarted.set_result(True) + if "uart_mock" in line and "Injecting " in line and "(injection " in line: + rx_injected_at.append(loop.time()) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if ( + "No data for 5 s (0 frames dropped), starting over" in line + and not link_lost.done() + ): + config_start_before_loss = tx_frames.count(CONFIG_START_TX) + link_lost.set_result(loop.time()) + if ( + link_lost.done() + and "ld2410s set Warning flag" in line + and not warning_set_after_loss.done() + ): + warning_set_after_loss.set_result(True) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) >= len(EXPECTED_LINK_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(setup_done, 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + await _start_scenario(client, collector) + try: + await asyncio.wait_for(all_presence, timeout=10.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + assert collector.binary_states["has_target"] == EXPECTED_LINK_PRESENCE + + try: + lost_at = await asyncio.wait_for(link_lost, timeout=7.0) + await asyncio.wait_for( + asyncio.gather(warning_set_after_loss, restarted), timeout=1.0 + ) + except TimeoutError: + pytest.fail( + f"No link-lost warning, warning flag or restart. TX frames: {tx_frames}" + ) + + # The stream ran past the link timeout, so only the silence after it counts + silence = lost_at - rx_injected_at[-1] + assert silence >= LINK_TIMEOUT_S - 0.5, ( + f"Link declared lost {silence:.2f} s after the last frame" + ) + + # The init sequence starts over with a new config-mode start + assert config_start_before_loss == 1, tx_frames + assert tx_frames.count(CONFIG_START_TX) == 2, tx_frames diff --git a/tests/integration/test_uart_mock_ld2460.py b/tests/integration/test_uart_mock_ld2460.py new file mode 100644 index 0000000000..4374ee92d7 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2460.py @@ -0,0 +1,160 @@ +"""Integration test for the LD2460 component with a mock UART. + +The frames come from the HLK-LD2460 serial protocol v1.0: the report example in +table 2 (one target at 1.5 m, 2.3 m), the installation-mode query in table 11 and +the receipts in table 12. + +test_uart_mock_ld2460: + 1. The hub queries the installation mode at boot (TX logged by the mock) + 2. The datasheet report example decodes to x, y, distance and angle (side mount) + 3. Garbage, a truncated frame and a buffer overflow do not stop the parser + 4. A two-target frame after the overflow decodes, including a negative X + 5. A top-mount receipt switches the angle formula for the next frame +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_ld2460( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2460 frame parsing with the datasheet frames, garbage and overflow.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + overflow_seen = loop.create_future() + tx_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Max frame length exceeded" in line and not overflow_seen.done(): + overflow_seen.set_result(True) + if "uart_mock" in line and "TX " in line: + tx_log_lines.append(line) + + collector = SensorStateCollector( + sensor_names=[ + "target_count", + "target_1_x", + "target_1_y", + "target_1_distance", + "target_1_angle", + "target_2_x", + "target_2_y", + "target_2_distance", + "target_2_angle", + ], + ) + + # Phase 5: two targets after the overflow + recovery_received = collector.add_waiter( + lambda: ( + pytest.approx(2.0) in collector.sensor_states["target_count"] + and pytest.approx(-2.0, abs=0.01) in collector.sensor_states["target_1_x"] + and pytest.approx(3.0, abs=0.01) in collector.sensor_states["target_1_y"] + and pytest.approx(0.5, abs=0.01) in collector.sensor_states["target_2_x"] + and pytest.approx(1.0, abs=0.01) in collector.sensor_states["target_2_y"] + and pytest.approx(1.118, abs=0.01) + in collector.sensor_states["target_2_distance"] + ) + ) + # Phase 6: the same target as phase 1, measured as a top mount + top_mount_received = collector.add_waiter( + lambda: ( + pytest.approx(56.9, abs=0.1) in collector.sensor_states["target_1_angle"] + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Phase 1: the datasheet example, one target at (1.5, 2.3) + phase1 = collector.add_waiter( + lambda: ( + pytest.approx(1.0) in collector.sensor_states["target_count"] + and pytest.approx(33.1, abs=0.1) + in collector.sensor_states["target_1_angle"] + ) + ) + try: + await asyncio.wait_for(phase1, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + assert collector.sensor_states["target_1_x"][0] == pytest.approx(1.5, abs=0.01) + assert collector.sensor_states["target_1_y"][0] == pytest.approx(2.3, abs=0.01) + assert collector.sensor_states["target_1_distance"][0] == pytest.approx( + 2.746, abs=0.01 + ) + assert collector.sensor_states["target_1_angle"][0] == pytest.approx( + 33.1, abs=0.1 + ) + assert collector.sensor_states["target_count"][0] == pytest.approx(1.0) + + # Phase 5: parsing survives garbage, a truncated frame and an overflow + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + assert overflow_seen.done(), "Expected 'Max frame length exceeded' in the log" + assert ( + pytest.approx(3.606, abs=0.01) + in collector.sensor_states["target_1_distance"] + ) + assert ( + pytest.approx(-33.7, abs=0.1) in collector.sensor_states["target_1_angle"] + ) + assert pytest.approx(26.6, abs=0.1) in collector.sensor_states["target_2_angle"] + + # Phase 6: the top-mount receipt changes the angle for the same coordinates + try: + await asyncio.wait_for(top_mount_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the top-mount angle. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + + # The hub sent the installation-mode query from the datasheet (table 11) + tx_data = " ".join(tx_log_lines) + assert "FD:FC:FB:FA:0A:0C:00:01:04:03:02:01" in tx_data, ( + f"Expected the installation-mode query in the TX log, got: {tx_data}" + ) diff --git a/tests/integration/test_udp_ipv6.py b/tests/integration/test_udp_ipv6.py new file mode 100644 index 0000000000..19b8eb5fe4 --- /dev/null +++ b/tests/integration/test_udp_ipv6.py @@ -0,0 +1,63 @@ +"""Integration test for UDP over IPv6 with the default dual-stack listen socket.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +def _free_port(family: socket.AddressFamily, host: str) -> int: + with socket.socket(family, socket.SOCK_DGRAM) as sock: + sock.bind((host, 0)) + return sock.getsockname()[1] + + +@pytest.mark.asyncio +async def test_udp_ipv6_unicast( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """IPv6 and IPv4 datagrams both reach the listen socket, and replies go out over IPv6.""" + if not socket.has_ipv6: + pytest.skip("Host has no IPv6 support") + try: + reply_sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + reply_sock.bind(("::1", 0)) + except OSError: + pytest.skip("Host has no IPv6 loopback") + reply_sock.setblocking(False) + reply_port = reply_sock.getsockname()[1] + listen_port = _free_port(socket.AF_INET6, "::") + config = yaml_config.replace( + "UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port) + ).replace("UDP_REPLY_PORT_PLACEHOLDER", str(reply_port)) + + loop = asyncio.get_running_loop() + try: + async with ( + run_compiled(config), + api_client_connected() as client, + ): + assert await client.device_info() is not None + + for family, host in ( + (socket.AF_INET6, "::1"), + # An IPv4 sender reaches the IPv6 socket as a mapped address + (socket.AF_INET, "127.0.0.1"), + ): + with socket.socket(family, socket.SOCK_DGRAM) as send_sock: + send_sock.sendto(b"PING", (host, listen_port)) + try: + data = await asyncio.wait_for( + loop.sock_recv(reply_sock, 64), timeout=5.0 + ) + except TimeoutError: + pytest.fail(f"No reply over IPv6 to a PING sent to {host}") + assert data == b"PONG" + finally: + reply_sock.close() diff --git a/tests/integration/test_udp_multicast.py b/tests/integration/test_udp_multicast.py new file mode 100644 index 0000000000..98a9059142 --- /dev/null +++ b/tests/integration/test_udp_multicast.py @@ -0,0 +1,75 @@ +"""Integration test for the UDP component's deferred multicast join.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +MULTICAST_GROUP = "239.1.2.3" + + +def _get_free_udp_port() -> int: + """Get a free UDP port by binding to port 0 and releasing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +@pytest.mark.asyncio +async def test_udp_multicast_join( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The group is joined from the poll after setup, once, and then receives.""" + log_lines: list[str] = [] + joined_event = asyncio.Event() + receive_event = asyncio.Event() + + def on_log_line(line: str) -> None: + log_lines.append(line) + if f"Joined multicast {MULTICAST_GROUP}" in line: + joined_event.set() + if "Received UDP:" in line: + receive_event.set() + + listen_port = _get_free_udp_port() + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + + async with ( + run_compiled(config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "udp-multicast-test" + + try: + await asyncio.wait_for(joined_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail(f"Multicast group was not joined. Log: {log_lines[-20:]}") + + # The poll cancels itself after the join, so a second poll period must not join again + await asyncio.sleep(2.5) + join_lines = [line for line in log_lines if "Joined multicast" in line] + assert len(join_lines) == 1, f"Expected one join, got: {join_lines}" + assert not any("IP_ADD_MEMBERSHIP" in line for line in log_lines), ( + f"Join reported a failure. Log: {log_lines[-20:]}" + ) + + send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + send_sock.sendto(b"MULTICAST_PAYLOAD", (MULTICAST_GROUP, listen_port)) + finally: + send_sock.close() + + try: + await asyncio.wait_for(receive_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail(f"Multicast datagram was not received. Log: {log_lines[-20:]}") diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 010313db7f..4a89a1fb91 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -125,6 +125,11 @@ Components using `modbus` packages automatically get `uart` as well. - **RP2040**: SCL=GPIO5, SDA=GPIO4 - **BK72xx**: SCL=P20, SDA=P21 +### 1-Wire +- **ESP32 IDF**: GPIO4 +- **ESP8266**: GPIO13 +- **RP2040**: GPIO10 + ### I2C Low Frequency (10kHz) Same pin allocations as standard I2C, but with 10kHz frequency for components requiring slower speeds. diff --git a/tests/test_build_components/common/one_wire/esp32-idf.yaml b/tests/test_build_components/common/one_wire/esp32-idf.yaml new file mode 100644 index 0000000000..b34e87a758 --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp32-idf tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO4 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/esp8266-ard.yaml b/tests/test_build_components/common/one_wire/esp8266-ard.yaml new file mode 100644 index 0000000000..c4fefffd3e --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp8266-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO13 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/rp2040-ard.yaml b/tests/test_build_components/common/one_wire/rp2040-ard.yaml new file mode 100644 index 0000000000..8023264e40 --- /dev/null +++ b/tests/test_build_components/common/one_wire/rp2040-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for rp2040-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO10 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml b/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml new file mode 100644 index 0000000000..d73aca27d7 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for BK72XX Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO17 + +remote_receiver: + id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% diff --git a/tests/unit_tests/components/remote_base/test_panasonic.py b/tests/unit_tests/components/remote_base/test_panasonic.py new file mode 100644 index 0000000000..3f0e8794c0 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_panasonic.py @@ -0,0 +1,49 @@ +"""A Panasonic address_2 is only sent with a 56-bit code.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_ADDRESS_2, + PANASONIC_SCHEMA, + _panasonic_address_2_needs_56_bits, +) +import esphome.config_validation as cv +from esphome.const import CONF_NBITS +from esphome.core import Lambda + +BASE = {"address": 0x4004, "command": 0x0100BCBD} + + +def test_address_2_needs_56_bits() -> None: + config = PANASONIC_SCHEMA({**BASE, CONF_NBITS: 56, CONF_ADDRESS_2: 0x12}) + assert _panasonic_address_2_needs_56_bits(config)[CONF_ADDRESS_2] == 0x12 + with pytest.raises(cv.Invalid, match="only sent with nbits: 56"): + _panasonic_address_2_needs_56_bits( + PANASONIC_SCHEMA({**BASE, CONF_NBITS: 48, CONF_ADDRESS_2: 0x12}) + ) + + +def test_zero_address_2_is_fine_with_48_bits() -> None: + config = PANASONIC_SCHEMA({**BASE, CONF_ADDRESS_2: 0}) + assert _panasonic_address_2_needs_56_bits(config)[CONF_NBITS] == 48 + + +def test_lambda_address_2_is_not_checked_here() -> None: + config = {**BASE, CONF_NBITS: 48, CONF_ADDRESS_2: Lambda("return 0x12;")} + assert isinstance( + _panasonic_address_2_needs_56_bits(config)[CONF_ADDRESS_2], Lambda + ) + + +def test_transmit_action_checks_address_2() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_panasonic"].raw_schema + assert schema({**BASE, CONF_NBITS: 56, CONF_ADDRESS_2: 0x12})[CONF_NBITS] == 56 + assert isinstance( + schema({**BASE, CONF_ADDRESS_2: Lambda("return 0x12;")})[CONF_ADDRESS_2], + Lambda, + ) + with pytest.raises(cv.Invalid, match="only sent with nbits: 56"): + schema({**BASE, CONF_ADDRESS_2: 0x12}) diff --git a/tests/unit_tests/components/remote_base/test_pioneer_wyt.py b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py new file mode 100644 index 0000000000..fd9e5afe4a --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py @@ -0,0 +1,56 @@ +"""Schema checks for the pioneer_wyt transmit action.""" + +import pytest + +from esphome.automation import ACTION_REGISTRY +import esphome.config_validation as cv +from esphome.core import Lambda + +# Factory remote capture: cool mode, 26°C, without the trailing checksum byte +RAW_CODE = [ + 0x23, + 0xCB, + 0x26, + 0x01, + 0x00, + 0x24, + 0x03, + 0x05, + 0x00, + 0x00, + 0x00, + 0x00, + 0x80, +] +RAW_CODE_CHECKSUM = 0xC1 + + +@pytest.fixture +def schema() -> cv.Schema: + return ACTION_REGISTRY["remote_transmitter.transmit_pioneer_wyt"].raw_schema + + +def test_code_checksum_matches_capture() -> None: + assert sum(RAW_CODE) & 0xFF == RAW_CODE_CHECKSUM + + +def test_code_accepts_with_and_without_checksum_byte(schema: cv.Schema) -> None: + with_checksum = [*RAW_CODE, RAW_CODE_CHECKSUM] + assert schema({"code": RAW_CODE})["code"] == RAW_CODE + assert schema({"code": with_checksum})["code"] == with_checksum + + +@pytest.mark.parametrize("code", [RAW_CODE[:-1], [*RAW_CODE, RAW_CODE_CHECKSUM, 0x00]]) +def test_code_rejects_other_lengths(schema: cv.Schema, code: list[int]) -> None: + with pytest.raises(cv.Invalid): + schema({"code": code}) + + +def test_code_accepts_lambda(schema: cv.Schema) -> None: + code = Lambda("return {0x23, 0xCB};") + assert isinstance(schema({"code": code})["code"], Lambda) + + +def test_code_is_required(schema: cv.Schema) -> None: + with pytest.raises(cv.Invalid): + schema({}) diff --git a/tests/unit_tests/components/remote_base/test_protocol_defines.py b/tests/unit_tests/components/remote_base/test_protocol_defines.py new file mode 100644 index 0000000000..fdf6997440 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_protocol_defines.py @@ -0,0 +1,20 @@ +"""Every gated protocol source needs its define listed for static analysis.""" + +from pathlib import Path + +from esphome.components.remote_base import _PROTOCOL_STEMS, protocol_define + +DEFINES_H = Path(__file__).parents[4] / "esphome" / "core" / "defines.h" + + +def test_every_protocol_define_is_listed_in_defines_h() -> None: + """A protocol compiled only behind its define would drop out of clang-tidy without this line.""" + defines = DEFINES_H.read_text(encoding="utf-8") + missing = [ + stem + for stem in _PROTOCOL_STEMS + if f"#define {protocol_define(stem)}\n" not in defines + ] + assert not missing, ( + f"Add #define {', '.join(protocol_define(s) for s in missing)} to {DEFINES_H.name}" + ) diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py new file mode 100644 index 0000000000..3015527c18 --- /dev/null +++ b/tests/unit_tests/components/test_web_server.py @@ -0,0 +1,172 @@ +"""Tests for the web_server AP mode helpers.""" + +import logging + +import pytest + +from esphome.components.web_server import ( + _final_validate_ap_mode, + serve_captive, + serve_local, +) +from esphome.const import ( + CONF_AP, + CONF_LOCAL, + CONF_NETWORKS, + CONF_PORT, + CONF_SSID, + CONF_VERSION, + CONF_WIFI, +) +import esphome.final_validate as fv + +AP_ONLY = {CONF_AP: {}} +AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]} +STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]} + + +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: embed the interface, the AP has no internet. + ({CONF_VERSION: 2}, AP_ONLY, True), + ({CONF_VERSION: 3}, AP_ONLY, True), + # Explicit setting always wins. + ({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True), + # AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page. + ({CONF_VERSION: 2}, AP_FALLBACK, False), + ({CONF_VERSION: 2}, STA_ONLY, False), + ({CONF_VERSION: 2}, None, False), + ({CONF_VERSION: 1}, AP_ONLY, False), + ], +) +def test_serve_local( + web_server_config: dict, wifi_config: dict | None, expected: bool +) -> None: + """The interface is embedded for AP only WiFi unless local is set explicitly.""" + assert serve_local(web_server_config, wifi_config) is expected + + +@pytest.mark.parametrize( + ("web_server_config", "full_config", "expected"), + [ + # AP only: local is implied, web_server is the captive portal. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True), + # Captive portal probes only work on port 80. + ({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False), + # AP fallback needs an explicit local: true to be captive. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True), + # captive_portal owns the role when configured. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False), + # No AP, no wifi, hosted page, or version 1: never captive. + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False), + ({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False), + ({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False), + ], +) +def test_serve_captive( + web_server_config: dict, full_config: dict, expected: bool +) -> None: + web_server_config.setdefault(CONF_PORT, 80) + assert serve_captive(web_server_config, full_config) is expected + + +@pytest.mark.parametrize( + ("web_server_config", "expect_warning"), + [ + # Explicit local: false on an AP only device: the hosted page will stay blank. + ({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True), + # Default: embedded and captive, nothing to warn about. + ({CONF_VERSION: 2, CONF_PORT: 80}, False), + ], +) +def test_final_validate_ap_mode_warns_for_hosted_page( + web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture +) -> None: + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert ("stays blank" in caplog.text) is expect_warning + + +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: local is implied, so the port alone blocks captive mode. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_ONLY, "on the access point"), + # Fallback AP with local: true opted into captive mode; say why it is skipped. + ( + {CONF_VERSION: 2, CONF_PORT: 8080, CONF_LOCAL: True}, + AP_FALLBACK, + "on the fallback access point", + ), + # Fallback AP without local: hosted page, nothing was promised. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_FALLBACK, None), + ], +) +def test_final_validate_ap_mode_warns_for_non_default_port( + web_server_config: dict, + wifi_config: dict, + expected: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Captive portal detection needs port 80; other ports get a hint, not captive mode.""" + token = fv.full_config.set( + {"web_server": web_server_config, CONF_WIFI: wifi_config} + ) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + if expected is None: + assert "cannot open automatically" not in caplog.text + return + assert expected in caplog.text + assert "http://192.168.4.1:8080/" in caplog.text + + +def test_final_validate_ap_mode_port_warning_uses_manual_ip( + caplog: pytest.LogCaptureFixture, +) -> None: + """The manual URL in the port warning honors wifi.ap.manual_ip.""" + from esphome.const import CONF_MANUAL_IP, CONF_STATIC_IP + + config = {CONF_VERSION: 2, CONF_PORT: 8080} + wifi = {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}} + token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(config) + finally: + fv.full_config.reset(token) + assert "http://10.0.0.1:8080/" in caplog.text + + +@pytest.mark.parametrize( + ("wifi_config", "expected"), + [ + # Explicit local: true on a fallback AP: announce the captive fallback role. + (AP_FALLBACK, "fallback access point"), + # Explicit local: true on AP only skips the implied-local info; still announce. + (AP_ONLY, "captive portal while the access point"), + ], +) +def test_final_validate_ap_mode_informs_explicit_local_captive( + wifi_config: dict, expected: str, caplog: pytest.LogCaptureFixture +) -> None: + """Explicit local: true logs that web_server becomes the captive portal.""" + config = {CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: True} + token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi_config}) + try: + with caplog.at_level(logging.INFO): + _final_validate_ap_mode(config) + finally: + fv.full_config.reset(token) + assert expected in caplog.text diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 7835156b01..6bffc434ee 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -17,6 +17,7 @@ from unittest.mock import Mock, patch import pytest +from esphome import yaml_util from esphome.core import CORE from esphome.espidf import toolchain @@ -49,6 +50,18 @@ def reset_core(): CORE.reset() +@pytest.fixture(autouse=True) +def clear_yaml_secrets() -> Generator[None]: + """Isolate the yaml_util secrets registry between tests.""" + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yaml_util._LOADED_SECRET_NAMES.clear() + yield + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yaml_util._LOADED_SECRET_NAMES.clear() + + @pytest.fixture def fixture_path() -> Path: """ diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f2d4628044..fe0b1b02ed 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1293,13 +1293,25 @@ async def test_apply_condition_compares_config_value( @pytest.mark.asyncio -@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) -async def test_apply_condition_string_constant_is_a_plain_literal( - registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +@pytest.mark.parametrize( + ("platform", "value", "expected"), + [ + ("esp32", "two", '"two"'), + ("esp8266", "two", '::esphome::ProgmemStringRef{PSTR("two"), 3}'), + # The length is in bytes, not characters + ("esp8266", "é", '::esphome::ProgmemStringRef{PSTR("\\303\\251"), 2}'), + ], +) +async def test_apply_condition_string_constant( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + value: str, + expected: str, ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) - await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) + await _run_apply_condition(registries, check, {"state": value}, platform=platform) + assert f"return ::{PARENT_OBJ}->state == {expected};" in _apply_definition(mock_cg) @pytest.mark.asyncio diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 8a2510cf0a..fe4bf19715 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -139,10 +139,17 @@ def test_find_used_secret_keys_no_secrets(tmp_path: Path) -> None: assert keys == set() -def test_find_used_secret_keys_missing_file(tmp_path: Path) -> None: +def test_find_used_secret_keys_missing_file( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: missing = tmp_path / "does_not_exist.yaml" - keys = _find_used_secret_keys([missing]) + with caplog.at_level("WARNING", logger="esphome.bundle"): + keys = _find_used_secret_keys([missing]) assert keys == set() + assert any( + "Could not scan" in r.message and "does_not_exist.yaml" in r.message + for r in caplog.records + ) def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: @@ -1356,7 +1363,8 @@ def test_discover_files_candidate_outside_config_dir_skipped( def test_discover_files_nested_include_load_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A nested !include pointing at a missing file is logged and skipped.""" + """A nested !include pointing at a missing file is reported in the bundle + summary, not a second time by yaml_util, and skipped.""" config_dir = _setup_config_dir(tmp_path) (config_dir / "test.yaml").write_text( "esphome:\n name: test\nwifi: !include missing.yaml\n" @@ -1367,10 +1375,12 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any( - "failed to load !include" in r.message.lower() and "missing.yaml" in r.message - for r in caplog.records + m.startswith("Bundle may be incomplete") and "missing.yaml" in m + for m in warnings ) + assert not any("failed to load !include" in m.lower() for m in warnings) def test_force_load_skips_duplicate_include_file() -> None: diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 742f07c8de..0f68ccb34a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import importlib import io import json @@ -11,7 +12,7 @@ from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation as cv +from esphome import config_validation as cv, cpp_generator from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -69,6 +70,8 @@ from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry from esphome.yaml_util import ( ESPHomeDataBase, + SensitiveHexInt, + SensitiveInt, SensitiveStr, load_yaml, make_data_base, @@ -201,12 +204,13 @@ def test_sensitive__wraps_string_result_in_sensitive_str() -> None: assert result == "hunter2" -def test_sensitive__does_not_double_tag_already_sensitive() -> None: - # If the inner validator already returns a SensitiveStr (e.g., nested +@pytest.mark.parametrize( + "pre_tagged", [SensitiveStr("hunter2"), SensitiveInt(5), SensitiveHexInt(0x12)] +) +def test_sensitive__does_not_double_tag_already_sensitive(pre_tagged: object) -> None: + # If the inner validator already returns a tagged value (e.g., nested # cv.sensitive wrappers), re-tagging is a no-op rather than a new - # SensitiveStr around the same value. - pre_tagged = SensitiveStr("hunter2") - + # wrapper around the same value. def inner(_value): return pre_tagged @@ -216,16 +220,33 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: assert result is pre_tagged -def test_sensitive__non_string_result_passes_through() -> None: - # If an inner validator returns something other than a string (e.g., a - # Lambda template), the sensitive wrapper must not coerce it. - sentinel = object() - +@pytest.mark.parametrize("passthrough", [object(), True, False]) +def test_sensitive__non_string_result_passes_through(passthrough: object) -> None: + # If an inner validator returns something other than a string or integer + # (e.g., a Lambda template or a bool), the sensitive wrapper must not + # coerce it. def inner(_value): - return sentinel + return passthrough validator = cv.sensitive(inner) - assert validator("anything") is sentinel + assert validator("anything") is passthrough + + +@pytest.mark.parametrize("inner", [cv.uint32_t, cv.hex_int]) +def test_sensitive__integer_preserves_codegen(inner: Callable[[str], int]) -> None: + """Tag credentials without changing their firmware expressions.""" + original = inner("0x12345678") + tagged = cv.sensitive(inner)("0x12345678") + assert isinstance(tagged, SensitiveInt) + assert tagged == original + assert str(cpp_generator.safe_exp(tagged)) == str(cpp_generator.safe_exp(original)) + assert isinstance(tagged, HexInt) == isinstance(original, HexInt) + + +def test_sensitive__integer_validation_still_rejects_out_of_range() -> None: + """Keep the inner validator's range checks.""" + with pytest.raises(Invalid): + cv.sensitive(cv.uint32_t)(1 << 32) def test_sensitive__is_detectable_via_isinstance() -> None: diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f4c5935059..f1e04da8c8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -263,6 +263,7 @@ def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) - espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, "An error occurred while updating the bootloader", ), + (espota2.RESPONSE_ERROR_UPDATE_BUSY, "The device is busy with another update"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) @@ -799,6 +800,23 @@ def test_run_ota_impl_connection_failed( mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_old_key_reconnect_never_waits( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """The reconnect with old_key skips the retry delay even once the + address list has been cycled by an earlier connect failure.""" + mock_socket.connect.side_effect = [OSError("timed out"), None, None] + mock_perform_ota.side_effect = [espota2.OTAKeyRejected("rejected"), None] + + result_code, _ = espota2.run_ota_impl_( + "test.local", 3232, None, str(firmware_file), noise_psk="a", old_noise_psk="b" + ) + + assert result_code == 0 + assert mock_sleep.call_count == 1 # only the connect failure waited + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_connect_retry_succeeds( mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 60f6b28e07..ca50641309 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -70,10 +70,12 @@ class FakeEncryptedDevice(threading.Thread): prologue_features_override: int | None = None, connections: int = 1, drop_handshakes: int = 0, + reject_reason: str | None = None, ) -> None: super().__init__(daemon=True) self.connections = connections self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times + self.reject_reason = reject_reason # refuse every handshake with this reason self.psk = psk self.version = version self.offer_noise = offer_noise @@ -160,6 +162,9 @@ class FakeEncryptedDevice(threading.Thread): if self.drop_handshakes > 0: self.drop_handshakes -= 1 return # a transport fault: the socket closes with no reply + if self.reject_reason is not None: + _send_frame(sock, b"\x01" + self.reject_reason.encode()) + return try: proto.read_message(msg1[1:]) except InvalidTag: @@ -244,8 +249,9 @@ def _run_ota( tmp_path: Path, noise_psk: str, plaintext_fallback: bool = True, + old_noise_psk: str | None = None, ) -> int: - """Drive the retry loop, which is where the plaintext fallback reconnects.""" + """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" path.write_bytes(firmware) device.start() @@ -256,10 +262,62 @@ def _run_ota( path, noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + old_noise_psk=old_noise_psk, ) return rc +THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() + + +@pytest.mark.parametrize( + ("device_psk", "old_noise_psk", "expected_rc", "retried"), + [ + # The device still runs the previous key: one reconnect with old_key + (OTHER_PSK, OTHER_PSK, 0, True), + # The device already runs the new key: old_key is never presented + (PSK, OTHER_PSK, 0, False), + # Neither key matches: the retry is spent, then it fails closed + (THIRD_PSK, OTHER_PSK, 1, True), + # No old_key configured: a rejected key is a device error + (OTHER_PSK, None, 1, False), + ], + ids=["old_key_accepted", "key_accepted", "both_rejected", "no_old_key"], +) +def test_old_key_retry( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + device_psk: str, + old_noise_psk: str | None, + expected_rc: int, + retried: bool, +) -> None: + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice(psk=device_psk, connections=2 if retried else 1) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + firmware, + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=old_noise_psk, + ) + device.join_and_check() + assert rc == expected_rc + assert (device.received == firmware) is (expected_rc == 0) + assert ( + any("retrying with 'old_key'" in r.message for r in caplog.records) is retried + ) + assert any("accepted 'old_key'" in r.message for r in caplog.records) is ( + retried and expected_rc == 0 + ) + assert not any("plaintext" in r.message for r in caplog.records) + if expected_rc == 1 and old_noise_psk is not None: + assert any("rejected both" in r.message for r in caplog.records) + + def test_encrypted_upload_success() -> None: """A full encrypted v2 upload spanning several 8192-byte blocks.""" pytest.importorskip("aioesphomeapi.noise") @@ -495,21 +553,21 @@ def test_handshake_reject_with_other_reason() -> None: def test_handshake_garbage_second_message() -> None: - """A valid-looking point with a garbage MAC fails cleanly.""" + """A valid-looking point with a garbage MAC is a key failure.""" wrapper = _wrapper(_frame(b"\x00" + bytes(range(48)))) with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" + espota2.OTAKeyRejected, match="handshake failed; is the OTA encryption key" ): wrapper.do_handshake() def test_handshake_invalid_curve_point() -> None: - """An all-zero x25519 point is rejected as a clean error, not a crash.""" + """An all-zero x25519 point is a clean error, not a crash, and not a + key failure: it must not spend the old_key retry.""" wrapper = _wrapper(_frame(b"\x00" + bytes(48))) - with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" - ): + with pytest.raises(espota2.OTAError, match="handshake failed: ") as info: wrapper.do_handshake() + assert not isinstance(info.value, espota2.OTAKeyRejected) def test_recv_closed_at_frame_boundary_returns_empty() -> None: @@ -585,6 +643,29 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: wrapper._decrypt.decrypt.assert_called_once() +@pytest.mark.parametrize("plaintext_fallback", [False, True]) +def test_non_key_reject_reason_is_a_device_error( + caplog: pytest.LogCaptureFixture, tmp_path: Path, plaintext_fallback: bool +) -> None: + """Only a key failure spends the old_key retry or, until 2027.3.0, the + plaintext fallback; another reject reason fails at once.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(reject_reason="Busy") + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=plaintext_fallback, + old_noise_psk=OTHER_PSK, + ) + device.join_and_check() + assert rc == 1 + assert not any("retrying with 'old_key'" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b8330f159..801eda4496 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _read_ota_key, _redact_with_legacy_fallback, _resolve_network_devices, _should_subscribe_states, @@ -44,6 +45,7 @@ from esphome.__main__ import ( command_idedata, command_run, command_update_all, + command_upload, command_wizard, compile_program, detect_external_components, @@ -126,6 +128,7 @@ from esphome.espota2 import ( ) from esphome.platformio import toolchain from esphome.types import ConfigType +from esphome.upload_targets import PortType from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1550,6 +1553,17 @@ class MockArgs: bootloader: bool = False states: bool | None = None device: list[str] | None = None + prompt_ota_key: bool = False + ota_key: str | None = None + env_ota_key: str | None = None + + +# The keyword half of every run_ota call; a test overrides the one it is about +RUN_OTA_DEFAULT_KWARGS = { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + "old_noise_psk": None, +} def test_upload_program_serial_esp32( @@ -2114,32 +2128,39 @@ def test_upload_program_ota_success( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) +@pytest.mark.parametrize( + "old_key", + [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="], + ids=["key", "old_key"], +) def test_upload_program_ota_encryption_key( mock_run_ota: Mock, mock_get_port_type: Mock, tmp_path: Path, + old_key: str | None, + caplog: pytest.LogCaptureFixture, ) -> None: - """The resolved encryption key is passed through to run_ota.""" + """The resolved encryption key, and the previous one when set, are passed + through to run_ota.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) mock_get_port_type.return_value = "NETWORK" mock_run_ota.return_value = (0, "192.168.1.100") key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + encryption = {CONF_KEY: key} + if old_key is not None: + encryption["old_key"] = old_key config = { CONF_OTA: [ - { - CONF_PLATFORM: CONF_ESPHOME, - CONF_PORT: 3232, - CONF_ENCRYPTION: {CONF_KEY: key}, - } + {CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: encryption} ] } - exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) + with caplog.at_level(logging.WARNING): + exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) assert exit_code == 0 assert host == "192.168.1.100" @@ -2153,9 +2174,10 @@ def test_upload_program_ota_encryption_key( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"old_noise_psk": old_key}, ) + # The notice belongs to run, whose image was just built from this config + assert not any("remove 'old_key'" in r.message for r in caplog.records) def test_upload_program_bare_encryption_block_never_falls_back( @@ -2185,10 +2207,7 @@ def test_upload_program_bare_encryption_block_never_falls_back( with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: upload_program(config, MockArgs(), ["192.168.1.100"]) assert run_ota.call_args.args[5] == key - assert run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_allow_plaintext_upload( @@ -2217,12 +2236,427 @@ def test_upload_program_ota_allow_plaintext_upload( assert exit_code == 0 assert mock_run_ota.call_args.args[2] == "pw" assert mock_run_ota.call_args.args[5] == key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": True, + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS | { + "allow_plaintext_upload": True } +@pytest.mark.parametrize( + "ota_conf", + [ + { + CONF_ENCRYPTION: { + CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "old_key": "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=", + "allow_plaintext_upload": True, + } + }, + {}, + ], + ids=["with_encryption_block", "without_encryption_block"], +) +def test_upload_program_ota_prompted_key_is_presented( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + ota_conf: dict[str, Any], +) -> None: + """A prompted key replaces whatever the config holds for this upload and + never downgrades, whatever the config allows.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + old_key = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + config = { + CONF_API: { + CONF_ENCRYPTION: {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + }, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, **ota_conf}], + } + exit_code, _ = upload_program(config, MockArgs(ota_key=old_key), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[5] == old_key + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS + + +def test_upload_program_serial_ignores_prompted_key( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A serial upload has no handshake to present the key to.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = PortType.SERIAL + mock_upload_using_esptool.return_value = 0 + + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]} + with ( + patch("esphome.__main__.check_permissions"), + caplog.at_level(logging.WARNING), + ): + exit_code, _ = upload_program(config, MockArgs(ota_key="key"), ["/dev/ttyUSB0"]) + + assert exit_code == 0 + assert any("ignored for /dev/ttyUSB0" in r.message for r in caplog.records) + + +def test_command_run_prompts_for_ota_key_before_compile() -> None: + """The prompt comes first so it is not buried in minutes of build output.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, platform=PLATFORM_ESP32 + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + calls: list[str] = [] + + def read(prompt: str) -> str: + calls.append("prompt") + return key + + def compile_program(*_: Any) -> int: + calls.append("compile") + assert args.ota_key == key + return 1 # stop before the upload + + with ( + patch("esphome.__main__.read_secret_line", side_effect=read), + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", side_effect=compile_program), + ): + assert command_run(args, CORE.config) == 1 + assert calls == ["prompt", "compile"] + + +@pytest.mark.parametrize( + "old_key", [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="] +) +def test_command_run_says_when_old_key_can_go( + caplog: pytest.LogCaptureFixture, old_key: str | None +) -> None: + """Only run has just built the image from this config, so only run can + say the device will run `key` alone once it boots.""" + encryption = {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + if old_key is not None: + encryption["old_key"] = old_key + setup_core( + config={ + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_ENCRYPTION: encryption, + } + ] + }, + platform=PLATFORM_ESP32, + ) + args = MockArgs() + args.device = ["dev.local"] + args.no_logs = True + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch("esphome.__main__.get_serial_ports", return_value=[]), + patch("esphome.__main__.upload_program", return_value=(0, "dev.local")), + caplog.at_level(logging.WARNING), + ): + assert command_run(args, CORE.config) == 0 + assert any("remove 'old_key'" in r.message for r in caplog.records) is ( + old_key is not None + ) + + +def test_upload_program_platform_hook_ignores_prompted_key( + mock_get_port_type: Mock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A platform's own network upload path (nrf52 mcumgr) has no key + handshake; the key is dropped with a warning, never silently.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]} + with ( + patch( + "esphome.__main__.platform_hooks.get_platform_hook", + return_value=lambda *_: True, + ), + caplog.at_level(logging.WARNING), + ): + exit_code, host = upload_program(config, MockArgs(ota_key="key"), ["dev.local"]) + + assert (exit_code, host) == (0, "dev.local") + assert any("ignored for dev.local" in r.message for r in caplog.records) + + +def test_command_upload_reads_the_ota_key_first() -> None: + """The upload command takes the key before choosing a device, like run.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, platform=PLATFORM_ESP32 + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + args.device = None + with ( + patch("esphome.__main__.read_secret_line", return_value=key), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch( + "esphome.__main__.upload_program", return_value=(0, "dev.local") + ) as upload, + ): + assert command_upload(args, CORE.config) == 0 + assert args.ota_key == key + upload.assert_called_once() + + +def test_read_ota_key_stores_prompted_key() -> None: + """The key lands on args and never in argv.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]} + with patch("esphome.__main__.read_secret_line", return_value=key) as read: + _read_ota_key(args, config) + read.assert_called_once_with("OTA encryption key: ") + assert args.ota_key == key + + +def test_read_ota_key_not_requested() -> None: + args = MockArgs() + with patch("esphome.__main__.read_secret_line") as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key is None + + +def test_read_ota_key_from_environment() -> None: + """A calling program hands the key over in the environment.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(env_ota_key=key) + with patch("esphome.__main__.read_secret_line") as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key == key + + +def test_read_ota_key_empty_environment_value_is_an_error() -> None: + """An exported empty value is a failed lookup upstream, not "unset".""" + args = MockArgs(env_ota_key="") + with pytest.raises(EsphomeError, match="Invalid OTA encryption key"): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + + +def test_read_ota_key_prompt_wins_over_environment() -> None: + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs( + prompt_ota_key=True, env_ota_key="AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + ) + with patch("esphome.__main__.read_secret_line", return_value=key): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + assert args.ota_key == key + + +def test_read_ota_key_without_ota_block_is_left_to_the_upload() -> None: + """A serial flash config has no OTA; the key is ignored with a warning + at upload time instead of refusing here.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + with patch("esphome.__main__.read_secret_line", return_value=key): + _read_ota_key(args, {}) + assert args.ota_key == key + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "run", "--prompt-ota-key", "a.yaml", "b.yaml"], + ["esphome", "upload", "a.yaml", "b.yaml"], + ], + ids=["prompt", "environment"], +) +def test_run_esphome_refuses_one_key_for_several_configs( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + """Several configs run in child processes that never see the key.""" + from esphome.__main__ import run_esphome + + if "--prompt-ota-key" not in argv: + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + with pytest.raises(EsphomeError, match="take one configuration; 2 were given"): + run_esphome(argv) + + +def test_run_esphome_only_warns_for_a_command_with_one_string_config( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The wizard takes one config as a string; only upload and run count.""" + from esphome.__main__ import run_esphome + + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + wizard = Mock(return_value=0) + with ( + patch.dict("esphome.__main__.PRE_CONFIG_ACTIONS", {"wizard": wizard}), + caplog.at_level(logging.WARNING), + ): + assert run_esphome(["esphome", "wizard", "living-room.yaml"]) == 0 + assert any("does not use it" in r.message for r in caplog.records) + + +def test_run_esphome_scrubs_the_key_from_the_environment( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Every command takes the key out before any tool it runs could see it.""" + from esphome.__main__ import run_esphome + + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + version = Mock(return_value=0) + with ( + patch.dict("esphome.__main__.PRE_CONFIG_ACTIONS", {"version": version}), + caplog.at_level(logging.WARNING), + ): + run_esphome(["esphome", "version"]) + assert "ESPHOME_OTA_KEY" not in os.environ + assert version.call_args.args[0].env_ota_key == "x" + # A command that never uploads says so rather than dropping it silently + assert any("does not use it" in r.message for r in caplog.records) + + +@pytest.mark.parametrize("line", ["", "not-base64"], ids=["empty", "invalid"]) +def test_read_ota_key_rejects_bad_input(line: str) -> None: + args = MockArgs(prompt_ota_key=True) + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]} + with ( + patch("esphome.__main__.read_secret_line", return_value=line), + pytest.raises(EsphomeError, match="Invalid OTA encryption key"), + ): + _read_ota_key(args, config) + assert args.ota_key is None + + +@pytest.mark.parametrize( + ("device", "port_type", "ota_platform"), + [ + ("/dev/ttyUSB0", PortType.SERIAL, None), + ("/dev/ttyUSB0", PortType.SERIAL, CONF_WEB_SERVER), + ("SERIAL", PortType.NETWORK, None), + ], + ids=["port", "port_with_ota_platform", "selector"], +) +def test_read_ota_key_lets_a_serial_target_through( + mock_get_port_type: Mock, + caplog: pytest.LogCaptureFixture, + device: str, + port_type: PortType, + ota_platform: str | None, +) -> None: + """A serial flash of a web_server only config is not refused, whatever + --ota-platform says; the key is not asked for and the user is told it is + ignored. The SERIAL selector only resolves to a port later, so it counts + by name.""" + mock_get_port_type.return_value = port_type + args = MockArgs(prompt_ota_key=True, ota_platform=ota_platform) + args.device = [device] + with ( + patch("esphome.__main__.read_secret_line") as read, + caplog.at_level(logging.WARNING), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}) + read.assert_not_called() + assert args.ota_key is None + assert any(f"ignored for {device}" in r.message for r in caplog.records) + + +def test_read_ota_key_serial_then_network_still_asks( + mock_get_port_type: Mock, +) -> None: + """`--device SERIAL --device OTA` with no port attached ends up on the + network, so the key is read and a web_server only config is refused.""" + mock_get_port_type.side_effect = lambda d: ( + PortType.SERIAL if d.startswith("/dev") else PortType.NETWORK + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + args.device = ["/dev/ttyUSB0", "OTA"] + with patch("esphome.__main__.read_secret_line", return_value=key) as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_called_once() + assert args.ota_key == key + args = MockArgs(prompt_ota_key=True) + args.device = ["SERIAL", "OTA"] + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only apply to the esphome OTA platform"), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}) + read.assert_not_called() + + +def test_read_ota_key_ignored_for_a_host_build( + caplog: pytest.LogCaptureFixture, +) -> None: + """A host run never uploads, so nothing is asked for.""" + setup_core(platform=PLATFORM_HOST) + args = MockArgs(prompt_ota_key=True) + with ( + patch("esphome.__main__.read_secret_line") as read, + caplog.at_level(logging.WARNING), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key is None + assert any("host program" in r.message for r in caplog.records) + + +def test_read_ota_key_reports_a_bad_platform_before_prompting() -> None: + args = MockArgs(prompt_ota_key=True, ota_platform=CONF_WEB_SERVER) + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only provides"), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + + +@pytest.mark.parametrize( + ("ota_platform", "config"), + [ + ( + CONF_WEB_SERVER, + { + CONF_OTA: [ + {CONF_PLATFORM: CONF_ESPHOME}, + {CONF_PLATFORM: CONF_WEB_SERVER}, + ] + }, + ), + (None, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}), + ], + ids=["requested", "only_platform"], +) +@pytest.mark.parametrize("devices", [["192.168.1.100"], None], ids=["host", "chooser"]) +def test_read_ota_key_refuses_web_server_platform( + mock_get_port_type: Mock, + ota_platform: str | None, + config: dict[str, Any], + devices: list[str] | None, +) -> None: + """The HTTP path has no key handshake, so the flag is refused before the + prompt and before any compile, whether web_server was asked for or is the + only platform in the config, for a network target or none named yet.""" + mock_get_port_type.return_value = PortType.NETWORK + args = MockArgs(prompt_ota_key=True, ota_platform=ota_platform) + args.device = devices + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only apply to the esphome OTA"), + ): + _read_ota_key(args, config) + read.assert_not_called() + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2252,8 +2686,7 @@ def test_upload_program_ota_api_key_opportunistic( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=True, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"plaintext_fallback": True}, ) @@ -2282,10 +2715,7 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2344,8 +2774,7 @@ def test_upload_program_ota_with_file_arg( Path("custom.bin"), OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2401,8 +2830,7 @@ def test_upload_program_ota_partition_table_with_file_arg( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2465,8 +2893,7 @@ def test_upload_program_ota_partition_table_mqttip( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2655,8 +3082,7 @@ def test_upload_program_ota_bootloader_with_file_arg( bootloader_file, OTA_TYPE_UPDATE_BOOTLOADER, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2889,6 +3315,22 @@ def test_upload_program_web_server_only_auto_dispatches( mock_run_ota.assert_not_called() +def test_upload_program_web_server_refuses_a_presented_key( + mock_run_web_server_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A network target picked in the chooser reaches the HTTP path with the + key; it is refused rather than silently dropped.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + config = {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], CONF_WEB_SERVER: {}} + args = MockArgs(ota_key="AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=") + with pytest.raises(EsphomeError, match="only apply to the esphome OTA"): + upload_program(config, args, ["192.168.1.100"]) + mock_run_web_server_ota.assert_not_called() + + def test_upload_program_web_server_no_auth( mock_run_web_server_ota: Mock, mock_get_port_type: Mock, @@ -3151,8 +3593,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3208,8 +3649,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -4761,8 +5201,7 @@ def test_upload_program_ota_static_ip_with_mqttip( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -4813,8 +5252,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -4998,8 +5436,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -6445,6 +6882,17 @@ def test_parse_args_run_states() -> None: assert args.states is True +@pytest.mark.parametrize("command", ["upload", "run"]) +def test_parse_args_prompt_ota_key(command: str) -> None: + """The flag takes no value: the key is never accepted on the command line.""" + args = parse_args(["esphome", command, "--prompt-ota-key", "device.yaml"]) + assert args.prompt_ota_key is True + args = parse_args(["esphome", command, "device.yaml"]) + assert args.prompt_ota_key is False + with pytest.raises(SystemExit): + parse_args(["esphome", command, "--prompt-ota-key=abc", "device.yaml"]) + + def test_parse_args_run_states_default() -> None: """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 3f01fb22e6..8a60e9c388 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -561,6 +561,19 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: manager.set_download_utime.assert_not_called() +def test_prefetch_creates_http_cache_dir_first( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Eight resolve threads each build a registry client whose cache + init has no exist_ok; the dir must exist before any of them run.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + with patch.object(pf, "_project_platform_and_config", return_value=(None, None)): + pf._prefetch(tmp_path, "testenv") + assert (tmp_path / "http").is_dir() + + def test_main_interrupt_exits_quietly(tmp_path: Path) -> None: """Ctrl-C reaches the child via the shared process group; it must exit without a traceback.""" diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index 007455f45a..b108cf298a 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +from pathlib import Path import sys from types import ModuleType from unittest.mock import Mock @@ -110,6 +111,20 @@ def test_main_applies_registry_private_packages_patch( assert order == ["patch", "pio"] +def test_ensure_http_cache_dir_is_idempotent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The dir exists before PlatformIO's non-exist_ok makedirs, and a + second caller (another thread or build) does not trip over it.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path / "c")) + + runner.ensure_http_cache_dir() + assert (tmp_path / "c" / "http").is_dir() + runner.ensure_http_cache_dir() + + # Snapshot PlatformIO's own probe at import, before any test can patch it _PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 198b002d43..b0d3656101 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -988,6 +988,64 @@ def test_resolve_include_error_shows_expanded_from_when_substituted( assert "expanded from '${device}.yaml'" in msg +def test_resolve_include_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` in an included file surfaces as cv.Invalid naming that file. + + add_context() raises EsphomeError, so resolve_include's existing handler can + wrap it with the filename and document path. A bare TypeError would escape + that handler and reach the user as an unhandled traceback. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + include = yaml_util.IncludeFile(parent, "_network/ota.yaml", loader) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], substitutions.ContextVars()) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + +def test_do_packages_pass_nested_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` two package levels deep still names the offending file. + + Mirrors a real layout: the top-level config includes a network package whose + own `packages:` block includes the file with the empty `defaults:`. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def inner_loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + def outer_loader(_path: Path) -> dict[str, Any]: + return { + "packages": { + "otas": yaml_util.IncludeFile(parent, "_network/ota.yaml", inner_loader) + } + } + + config = OrderedDict( + { + "packages": { + "wifis": yaml_util.IncludeFile(parent, "network.yaml", outer_loader) + } + } + ) + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + def test_resolve_include_error_no_expanded_from_for_literal_filename( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index a4b091b7c2..739a0e2ada 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -1177,3 +1177,26 @@ class TestSafePrint: assert ( capsys.readouterr().out == "Cannot print line because of invalid locale!\n" ) + + +@pytest.mark.parametrize("is_tty", [True, False], ids=["tty", "pipe"]) +def test_read_secret_line(is_tty: bool) -> None: + """A terminal reads without echo, a pipe reads one line; both strip.""" + with ( + patch("esphome.util.sys.stdin") as stdin, + patch("getpass.getpass", return_value=" secret \n") as getpass, + ): + stdin.isatty.return_value = is_tty + stdin.readline.return_value = " secret \r\n" + assert util.read_secret_line("Key: ") == "secret" + assert getpass.called is is_tty + assert stdin.readline.called is not is_tty + + +def test_read_secret_line_empty_pipe(capsys: pytest.CaptureFixture[str]) -> None: + """The prompt still goes to stderr on a pipe so a blocked read is diagnosable.""" + with patch("esphome.util.sys.stdin") as stdin: + stdin.isatty.return_value = False + stdin.readline.return_value = "" + assert util.read_secret_line("Key: ") == "" + assert capsys.readouterr().err == "Key: " diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e1c3080e29..c3276823ec 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import io from pathlib import Path import shutil @@ -25,16 +26,6 @@ from esphome.yaml_util import ( ) -@pytest.fixture(autouse=True) -def clear_secrets_cache() -> None: - """Clear the secrets cache before each test.""" - yaml_util._SECRET_VALUES.clear() - yaml_util._SECRET_CACHE.clear() - yield - yaml_util._SECRET_VALUES.clear() - yaml_util._SECRET_CACHE.clear() - - @pytest.fixture(autouse=True) def clear_core_frontmatter() -> None: """Reset CORE.frontmatter between tests.""" @@ -578,6 +569,29 @@ def test_config_context_defaults_explicit_vars_override() -> None: assert "defaults" not in tagged +def test_config_context_defaults_empty_raises() -> None: + """Test that an empty defaults: key raises EsphomeError, not a bare TypeError. + + A ``defaults:`` block with an empty (or comments-only) body parses as None. + Unpacking it used to fail with ``TypeError: 'NoneType' object is not a + mapping``, which escapes the EsphomeError handler in resolve_include and + reaches the user as an unhandled traceback naming no file. + """ + data = {"defaults": None, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' is empty"): + yaml_util.add_context(data, None) + + +@pytest.mark.parametrize("defaults", ["oops", ["a", "b"], 42]) +def test_config_context_defaults_non_mapping_raises(defaults) -> None: + """Test that a non-mapping defaults: value raises EsphomeError naming the type.""" + data = {"defaults": defaults, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' must be a key to value mapping"): + yaml_util.add_context(data, None) + + def test_represent_extend() -> None: """Test that Extend objects are dumped as plain !extend scalars.""" assert yaml_util.dump({"key": Extend("my_id")}) == "key: !extend 'my_id'\n" @@ -1289,22 +1303,52 @@ def test_force_load_include_files_unresolved_log_level( assert matching == [expect_level] +def test_force_load_include_files_returns_unresolved_paths( + patch_include_file: None, +) -> None: + """Includes with substitution-templated paths are reported back to the + caller; resolvable ones are not.""" + templated = _StubInclude("${var}.yaml", unresolved=True) + plain = _StubInclude("ok.yaml") + result = force_load_include_files({"a": templated, "b": plain}) + assert result.unresolved == [str(templated.file)] + assert result.errors == [] + assert plain.load_calls == 1 + + def test_force_load_include_files_warns_on_load_failure( patch_include_file: None, caplog: pytest.LogCaptureFixture, ) -> None: - """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + """An `EsphomeError` raised by `load()` is caught, logged, and reported to + the caller — not propagated.""" stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) with caplog.at_level("WARNING", logger="esphome.yaml_util"): - force_load_include_files({"k": stub}) + result = force_load_include_files({"k": stub}) assert any( "Failed to load !include" in r.message and "missing.yaml" in r.message for r in caplog.records ) + assert result.errors == [f"{stub.file}: boom"] + assert result.unresolved == [] + + +def test_force_load_include_files_load_failure_quiet_when_caller_reports( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """With warn_on_load_error=False the failure is still returned but only + logged at debug, so a caller that reports it does not duplicate it.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + result = force_load_include_files({"k": stub}, warn_on_load_error=False) + assert result.errors == [f"{stub.file}: boom"] + failures = [r for r in caplog.records if "Failed to load !include" in r.message] + assert [r.levelname for r in failures] == ["DEBUG"] def test_discovered_yaml_files_holds_files_and_secrets() -> None: - """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + """`DiscoveredYamlFiles` is a small data carrier.""" files = [Path("/tmp/a.yaml")] secrets = {Path("/tmp/a.yaml")} discovered = DiscoveredYamlFiles(files, secrets) @@ -1367,11 +1411,24 @@ def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: assert target.resolve() in discovered.secrets -def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: - """A YAML parse failure returns whatever was tracked so far without raising.""" +def test_discover_user_yaml_files_reports_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure is surfaced in `.load_errors` (not raised), so + consumers can tell the file set is incomplete.""" entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") discovered = discover_user_yaml_files(entry) assert isinstance(discovered, DiscoveredYamlFiles) + assert len(discovered.load_errors) == 1 + assert "entry.yaml" in discovered.load_errors[0] + + +def test_discover_user_yaml_files_reports_unresolved_includes( + tmp_path: Path, +) -> None: + """A substitution-templated `!include` path is surfaced in `.unresolved`.""" + entry = _write_entry_including(tmp_path, "${board}.yaml") + discovered = discover_user_yaml_files(entry) + assert len(discovered.unresolved) == 1 + assert "${board}.yaml" in discovered.unresolved[0] def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: @@ -1598,9 +1655,9 @@ def test_discover_user_yaml_files_many_candidates_keep_nested_includes( def test_discover_user_yaml_files_bad_candidate_still_tracked( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A matched candidate that fails to parse warns even during discovery, - stays tracked (the load listener fires before parsing), and doesn't block - other candidates.""" + """A matched candidate that fails to parse is reported in ``load_errors`` + (logged only at debug, the caller reports it), stays tracked (the load + listener fires before parsing), and doesn't block other candidates.""" _write(tmp_path, "keys/good.yaml", "api:\n") _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") with caplog.at_level("DEBUG", logger="esphome.yaml_util"): @@ -1613,7 +1670,8 @@ def test_discover_user_yaml_files_bad_candidate_still_tracked( matching = [ r.levelname for r in caplog.records if "Failed to load candidate" in r.message ] - assert matching == ["WARNING"] + assert matching == ["DEBUG"] + assert any("bad.yaml" in e for e in discovered.load_errors) def test_discover_user_yaml_files_tolerates_templated_top_level_include( @@ -1901,9 +1959,39 @@ def test_dump__redacts_sensitive_str_by_default() -> None: ) # the raw value is only present inside the wrap -def test_dump__show_secrets_emits_sensitive_str_raw() -> None: - out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) - assert "hunter2" in out +@pytest.mark.parametrize( + ("validator", "value", "rendered"), + [ + (cv.uint32_t, 305419896, "305419896"), + ( + cv.hex_int, + 0x00112233445566778899AABBCCDDEEFF, + "0x112233445566778899AABBCCDDEEFF", + ), + (cv.int_, -42, "-42"), + ], +) +def test_dump__redacts_sensitive_integer( + validator: Callable[[int], int], value: int, rendered: str +) -> None: + """Mask integer credentials without changing their numeric value or format.""" + sensitive = cv.sensitive(validator)(value) + assert sensitive == value + assert str(sensitive) == rendered + out = yaml_util.dump({"credential": sensitive}) + assert f"\\033[8m{rendered}\\033[28m" in out + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (SensitiveStr("hunter2"), "password: hunter2"), + (cv.sensitive(cv.hex_int)(0x12345678), "password: 0x12345678"), + ], +) +def test_dump__show_secrets_emits_sensitive_raw(value: object, expected: str) -> None: + out = yaml_util.dump({"password": value}, show_secrets=True) + assert out.strip() == expected assert "\\033[8m" not in out assert "\\033[28m" not in out @@ -1914,19 +2002,20 @@ def test_dump__plain_str_is_not_redacted() -> None: assert "\\033[8m" not in out -def test_dump__secret_reference_wins_over_redaction() -> None: +@pytest.mark.parametrize( + "value", [SensitiveStr("hunter2"), cv.sensitive(cv.uint32_t)(305419896)] +) +def test_dump__secret_reference_wins_over_redaction( + value: object, monkeypatch: pytest.MonkeyPatch +) -> None: # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded # via !secret), the dump should render it as !secret , not as a - # redacted scalar. SensitiveStr layered on top must not change that. - value = SensitiveStr("hunter2") - yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" - try: - out = yaml_util.dump({"password": value}) - assert "!secret" in out - assert "my_secret_name" in out - assert "\\033[8m" not in out - finally: - yaml_util._SECRET_VALUES.clear() + # redacted scalar. The sensitive marker layered on top must not change that. + monkeypatch.setitem(yaml_util._SECRET_VALUES, str(value), "my_secret_name") + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out def test_dump__redaction_flag_does_not_leak_between_calls() -> None: @@ -1941,6 +2030,72 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted_again +def test_secret_values_registered_swaps_scalars_in_dump() -> None: + """Registered value→name mappings make dump() emit `!secret ` for + matching scalars, and are removed again on exit.""" + with yaml_util.secret_values_registered({"hunter2": "wifi_password"}): + out = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "password: !secret 'wifi_password'" in out + assert "hunter2" not in out + out_after = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "hunter2" in out_after + assert "!secret" not in out_after + assert yaml_util.is_secret("hunter2") is None + + +def test_secret_values_registered_collects_emitted_names() -> None: + """The context yields a set recording exactly the `!secret` names the + dumper emitted, including real secrets, and not names never swapped.""" + yaml_util._SECRET_VALUES["real_value"] = "real_name" + with yaml_util.secret_values_registered({"hunter2": "wifi_password"}) as emitted: + yaml_util.dump( + { + "password": make_data_base("hunter2"), + "key": make_data_base("real_value"), + "plain": make_data_base("nothing"), + } + ) + assert emitted == {"wifi_password", "real_name"} + + +def test_secret_values_registered_does_not_clobber_real_secrets() -> None: + """A value already mapped by a real `!secret` keeps its original name.""" + yaml_util._SECRET_VALUES["hunter2"] = "original_name" + with yaml_util.secret_values_registered({"hunter2": "generated_name"}): + out = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "!secret 'original_name'" in out + # The pre-existing mapping survives the context exit. + assert yaml_util.is_secret("hunter2") == "original_name" + + +def test_secret_values_registered_keeps_a_real_secret_loaded_meanwhile() -> None: + """A real `!secret` registered inside the context survives its exit.""" + with yaml_util.secret_values_registered({"hunter2": "generated_name"}): + yaml_util._SECRET_VALUES["hunter2"] = "real_name" + assert yaml_util.is_secret("hunter2") == "real_name" + + +def test_secret_values_registered_nested_keeps_the_outer_collector() -> None: + """The outer context also collects names emitted inside an inner one.""" + with yaml_util.secret_values_registered({"outer_value": "outer"}) as outer: + with yaml_util.secret_values_registered({"inner_value": "inner"}) as inner: + yaml_util.dump({"a": make_data_base("inner_value")}) + yaml_util.dump({"b": make_data_base("outer_value")}) + assert inner == {"inner"} + assert outer == {"inner", "outer"} + + +def test_registered_secret_names(tmp_path: Path) -> None: + """Every loaded name is returned, even two sharing a value, but not names + registered only for a dump.""" + (tmp_path / "secrets.yaml").write_text("wifi_password: same\nap_password: same\n") + main = tmp_path / "main.yaml" + main.write_text("a: !secret wifi_password\nb: !secret ap_password\n") + yaml_util.load_yaml(main) + with yaml_util.secret_values_registered({"other": "temp_name"}): + assert yaml_util.registered_secret_names() == {"wifi_password", "ap_password"} + + @pytest.fixture(autouse=True) def clear_dropped_merge_keys() -> None: """Reset the dropped-merge-key queue between tests.""" @@ -1986,6 +2141,42 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: assert yaml_util.take_dropped_merge_keys() == [] +def test_wrapper_representers_consult_is_secret( + caplog: pytest.LogCaptureFixture, +) -> None: + """!extend / !remove payloads and scalar !include paths equal to a + registered secret are swapped, never written in cleartext, and the lost + tag is logged.""" + from esphome.config_helpers import Extend, Remove + + with ( + caplog.at_level("WARNING", logger="esphome.yaml_util"), + yaml_util.secret_values_registered({"hunter2": "the_secret"}), + ): + out = yaml_util.dump( + { + "a": Extend("hunter2"), + "b": Remove("hunter2"), + "c": Extend("plain_id"), + } + ) + assert out.count("!secret 'the_secret'") == 2 + assert "hunter2" not in out + assert "!extend 'plain_id'" in out + lost = [r.message for r in caplog.records if "dropping the" in r.message] + assert len(lost) == 2 + assert all("hunter2" not in m for m in lost) + + +def test_scalar_include_path_equal_to_secret_is_swapped() -> None: + """A scalar !include whose path equals a registered secret is swapped, + matching the other wrapper representers.""" + include = yaml_util.IncludeFile(Path("/fake/main.yaml"), "hunter2", lambda _: {}) + with yaml_util.secret_values_registered({"hunter2": "the_secret"}): + out = yaml_util.dump({"key": include}) + assert out == "key: !secret 'the_secret'\n" + + # --------------------------------------------------------------------------- # track_document_range=False (validated-config-cache fast path) # --------------------------------------------------------------------------- @@ -2103,3 +2294,9 @@ def test_load_yaml_fast_mode_records_dropped_merge_keys( yaml_util.load_yaml(yaml_file, track_document_range=False) assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))] + + +def test_find_secret_references_in_flow_collections() -> None: + """Flow mapping and sequence punctuation is not part of the secret name.""" + text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n" + assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"}